@shivanshshrivas/gwit 0.1.2 → 0.2.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 (3) hide show
  1. package/README.md +82 -2
  2. package/dist/index.cjs +887 -61
  3. package/package.json +11 -3
package/README.md CHANGED
@@ -26,9 +26,16 @@ gwit -b fix/login-page # create a new branch from HEAD
26
26
 
27
27
  # Day-to-day
28
28
  gwit list # show all active worktrees
29
+ gwit status # see ahead/behind, dirty state, PR info
29
30
  gwit sync feature/auth # re-copy files after .env changes
31
+ gwit sync --back feature/auth # three-way merge .gwitinclude files back to main
30
32
  gwit open feature/auth # re-open editor for an existing worktree
33
+
34
+ # Merge and clean up
35
+ gwit merge feature/auth # merge branch back into main
31
36
  gwit remove feature/auth # run cleanup hooks and remove worktree
37
+ gwit sweep # bulk-remove all merged worktrees
38
+ gwit rename old/name new/name # rename a worktree branch
32
39
  ```
33
40
 
34
41
  ## How it works
@@ -102,13 +109,26 @@ gwit remove feature/auth --force # skip uncommitted-changes check
102
109
 
103
110
  ### `gwit sync [branch]`
104
111
 
105
- Re-copy `.gwitinclude` files into an existing worktree. Use this when your `.env` gains a new key, certs rotate, or `node_modules` is updated.
112
+ By default, re-copy `.gwitinclude` files from main into an existing worktree.
113
+ With `--back`, sync in the reverse direction using snapshot-aware three-way merge.
106
114
 
107
115
  ```sh
108
116
  gwit sync feature/auth # sync a specific branch
109
117
  gwit sync # auto-detect from current directory (when inside a worktree)
118
+ gwit sync --back feature/auth # three-way merge worktree -> main
119
+ gwit sync --back # auto-detect branch, then merge back
110
120
  ```
111
121
 
122
+ `--back` compares three versions of each snapshot-tracked file:
123
+
124
+ 1. **base** - file content captured when the worktree was created
125
+ 2. **main** - current file in the main worktree
126
+ 3. **worktree** - current file in the linked worktree
127
+
128
+ If both sides changed different text regions, gwit applies a clean merge.
129
+ If both sides changed the same region, gwit writes git-style conflict markers
130
+ (`<<<<<<<`, `=======`, `>>>>>>>`). Binary conflicts are skipped with a warning.
131
+
112
132
  ### `gwit open <branch>`
113
133
 
114
134
  Re-open the editor for an existing worktree. Useful when the editor window was closed.
@@ -118,6 +138,62 @@ gwit open feature/auth
118
138
  gwit open feature/auth --editor cursor
119
139
  ```
120
140
 
141
+ ### `gwit merge <branch>`
142
+
143
+ Merge a worktree branch back into the target branch. Combines git-tracked changes (via real git merge) with gitignored files (via reverse `.gwitinclude` sync) in a single command.
144
+
145
+ ```sh
146
+ gwit merge feature/auth # standard merge into default branch
147
+ gwit merge feature/auth --into dev # merge into a specific branch
148
+ gwit merge feature/auth --squash # squash all commits into one
149
+ gwit merge feature/auth --rebase # rebase onto target, then fast-forward
150
+ gwit merge feature/auth --no-ff # force a merge commit
151
+ gwit merge feature/auth --cleanup # remove worktree after successful merge
152
+ gwit merge feature/auth --no-sync-back # skip reverse .gwitinclude copy
153
+ ```
154
+
155
+ What it does, in order:
156
+
157
+ 1. Validates the worktree exists in the registry
158
+ 2. Resolves the target branch (default: repo's default branch)
159
+ 3. Three-way syncs `.gwitinclude` files back to main (falls back to direct copy if no snapshot; unless `--no-sync-back`)
160
+ 4. Checks out the target branch in the main worktree
161
+ 5. Merges the feature branch using the chosen strategy
162
+ 6. Optionally removes the worktree (`--cleanup`)
163
+
164
+ If the merge fails with conflicts, gwit exits with a helpful message so you can resolve manually.
165
+
166
+ ### `gwit status`
167
+
168
+ Show detailed status of all active gwit worktrees.
169
+
170
+ ```sh
171
+ gwit status # rich table output
172
+ gwit status --json # machine-readable JSON
173
+ ```
174
+
175
+ Displays per-worktree: branch, path, port, ahead/behind counts, dirty-file count, and PR info (if [`gh` CLI](https://cli.github.com) is installed).
176
+
177
+ ### `gwit sweep`
178
+
179
+ Bulk-remove worktrees whose branches are fully merged into the default branch or whose GitHub PRs are merged/closed.
180
+
181
+ ```sh
182
+ gwit sweep # interactive confirmation
183
+ gwit sweep --dry-run # show what would be removed
184
+ gwit sweep --force # skip confirmation
185
+ ```
186
+
187
+ Runs cleanup hooks for each removed worktree, just like `gwit remove`.
188
+
189
+ ### `gwit rename <old-branch> <new-branch>`
190
+
191
+ Rename a worktree's git branch, move the directory (if the slug changes), and update the registry atomically.
192
+
193
+ ```sh
194
+ gwit rename feature/auth feature/authentication
195
+ ```
196
+
121
197
  ### `gwit config`
122
198
 
123
199
  Show or update global configuration stored in `~/.gwitrc`.
@@ -145,15 +221,19 @@ gwit config set basePort 4000 # starting port for auto-assignment
145
221
 
146
222
  ### `.gwitinclude`
147
223
 
148
- List of gitignored files and directories to copy into every new worktree. One entry per line, comments with `#` ignored.
224
+ List of gitignored files and directories to copy into every new worktree. One entry per line, comments with `#` ignored. Supports glob patterns via [minimatch](https://github.com/isaacs/minimatch).
149
225
 
150
226
  ```
151
227
  # .gwitinclude - files to copy into each new worktree
152
228
  .env
153
229
  .env.local
154
230
  certs/
231
+ *.pem
232
+ secrets/**
155
233
  ```
156
234
 
235
+ Glob patterns (`*`, `?`, `[...]`, `**`) are expanded against the set of gitignored files in the repo. Literal entries are copied directly.
236
+
157
237
  Only gitignored files are eligible to copy. Tracked files are silently skipped - gwit is an allowlist for files that must be present but cannot be committed.
158
238
 
159
239
  ### `.gwitcommand`
package/dist/index.cjs CHANGED
@@ -24,8 +24,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
 
26
26
  // src/index.ts
27
- var fs8 = __toESM(require("fs"));
28
- var path6 = __toESM(require("path"));
27
+ var fs13 = __toESM(require("fs"));
28
+ var path8 = __toESM(require("path"));
29
29
  var import_commander = require("commander");
30
30
 
31
31
  // src/types.ts
@@ -54,21 +54,21 @@ function colorize(text, code) {
54
54
  return `${code}${text}${ANSI.reset}`;
55
55
  }
56
56
  var ui = {
57
- /** Prints a green success line prefixed with ✓. */
57
+ // Prints a green success line prefixed with ✓.
58
58
  success: (msg) => console.log(colorize(`\u2713 ${msg}`, ANSI.green)),
59
- /** Prints a red error line to stderr prefixed with ✗. */
59
+ // Prints a red error line to stderr prefixed with ✗.
60
60
  error: (msg) => console.error(colorize(`\u2717 ${msg}`, ANSI.red)),
61
- /** Prints a yellow warning line to stderr prefixed with ⚠. */
61
+ // Prints a yellow warning line to stderr prefixed with ⚠.
62
62
  warn: (msg) => console.warn(colorize(`\u26A0 ${msg}`, ANSI.yellow)),
63
- /** Prints a cyan informational line. */
63
+ // Prints a cyan informational line.
64
64
  info: (msg) => console.log(colorize(` ${msg}`, ANSI.cyan)),
65
- /** Prints a cyan step line prefixed with →. */
65
+ // Prints a cyan step line prefixed with →.
66
66
  step: (msg) => console.log(colorize(`\u2192 ${msg}`, ANSI.cyan)),
67
- /** Prints a dimmed line (secondary information). */
67
+ // Prints a dimmed line (secondary information).
68
68
  dim: (msg) => console.log(colorize(msg, ANSI.dim)),
69
- /** Returns bold-wrapped text (for embedding in other strings). */
69
+ // Returns bold-wrapped text (for embedding in other strings).
70
70
  bold: (text) => colorize(text, ANSI.bold),
71
- /** Returns gray-wrapped text (for embedding in other strings). */
71
+ // Returns gray-wrapped text (for embedding in other strings).
72
72
  gray: (text) => colorize(text, ANSI.gray)
73
73
  };
74
74
  function formatError(err) {
@@ -108,6 +108,12 @@ function getGwitDir() {
108
108
  function getRegistryPath() {
109
109
  return path.join(getGwitDir(), "worktrees.json");
110
110
  }
111
+ function getSnapshotsDir() {
112
+ return path.join(getGwitDir(), "snapshots");
113
+ }
114
+ function getSnapshotDir(slug) {
115
+ return path.join(getSnapshotsDir(), slug);
116
+ }
111
117
  function getConfigPath() {
112
118
  return path.join(os.homedir(), ".gwitrc");
113
119
  }
@@ -154,6 +160,18 @@ function runArgsSafe(cmd, args, options) {
154
160
  const stdout = (result.stdout ?? "").trim();
155
161
  return { stdout, success: result.status === 0 };
156
162
  }
163
+ function runArgsWithExitCode(cmd, args, options) {
164
+ const result = (0, import_child_process.spawnSync)(cmd, args, {
165
+ encoding: "utf-8",
166
+ cwd: options?.cwd,
167
+ env: options?.env ? { ...process.env, ...options.env } : process.env,
168
+ stdio: ["pipe", "pipe", "pipe"]
169
+ });
170
+ return {
171
+ stdout: (result.stdout ?? "").trim(),
172
+ exitCode: result.status ?? 1
173
+ };
174
+ }
157
175
  function runSafe(command, options) {
158
176
  try {
159
177
  const stdout = (0, import_child_process.execSync)(command, {
@@ -173,11 +191,11 @@ function _parseWorktreePorcelain(output) {
173
191
  const blocks = output.trim().split(/\n\n+/);
174
192
  return blocks.filter(Boolean).map((block) => {
175
193
  const lines = block.split("\n");
176
- const path7 = lines.find((l) => l.startsWith("worktree "))?.slice(9) ?? "";
194
+ const path9 = lines.find((l) => l.startsWith("worktree "))?.slice(9) ?? "";
177
195
  const head = lines.find((l) => l.startsWith("HEAD "))?.slice(5) ?? "";
178
196
  const branchLine = lines.find((l) => l.startsWith("branch "));
179
197
  const branch = branchLine ? branchLine.slice(7).replace("refs/heads/", "") : null;
180
- return { path: path7, head, branch };
198
+ return { path: path9, head, branch };
181
199
  }).filter((w) => w.path.length > 0);
182
200
  }
183
201
  function isGitRepo() {
@@ -210,6 +228,9 @@ function branchExistsRemote(branch) {
210
228
  const result = runArgsSafe("git", ["ls-remote", "--heads", "origin", branch]);
211
229
  return result.success && result.stdout.length > 0;
212
230
  }
231
+ function fetchOrigin() {
232
+ runArgsInherited("git", ["fetch", "origin"]);
233
+ }
213
234
  function addWorktree(worktreePath, branch, isNew) {
214
235
  const args = isNew ? ["worktree", "add", "-b", branch, worktreePath] : ["worktree", "add", worktreePath, branch];
215
236
  runArgsInherited("git", args);
@@ -223,6 +244,56 @@ function hasUncommittedChanges(worktreePath) {
223
244
  const result = runArgsSafe("git", ["-C", worktreePath, "status", "--porcelain"]);
224
245
  return result.success && result.stdout.length > 0;
225
246
  }
247
+ function isBranchMerged(branch, into) {
248
+ const result = runArgsSafe("git", ["branch", "--merged", into]);
249
+ if (!result.success) return false;
250
+ return result.stdout.split("\n").some((line) => line.trim().replace(/^\*\s*/, "") === branch);
251
+ }
252
+ function getDefaultBranch() {
253
+ const symref = runArgsSafe("git", ["symbolic-ref", "refs/remotes/origin/HEAD"]);
254
+ if (symref.success && symref.stdout.length > 0) {
255
+ return symref.stdout.replace("refs/remotes/origin/", "");
256
+ }
257
+ if (branchExistsLocal("main")) return "main";
258
+ if (branchExistsLocal("master")) return "master";
259
+ throw new GwitError(
260
+ "Could not determine the default branch.",
261
+ "Specify the target explicitly: gwit merge <branch> --into main"
262
+ );
263
+ }
264
+ function getAheadBehind(branch, base, cwd) {
265
+ const args = cwd ? ["-C", cwd, "rev-list", "--left-right", "--count", `${base}...${branch}`] : ["rev-list", "--left-right", "--count", `${base}...${branch}`];
266
+ const result = runArgsSafe("git", args);
267
+ if (!result.success) return { ahead: 0, behind: 0 };
268
+ const parts = result.stdout.split(" ");
269
+ return {
270
+ behind: parseInt(parts[0] ?? "0", 10) || 0,
271
+ ahead: parseInt(parts[1] ?? "0", 10) || 0
272
+ };
273
+ }
274
+ function mergeBranch(cwd, branch, noFf = false) {
275
+ const args = ["-C", cwd, "merge", branch];
276
+ if (noFf) args.splice(3, 0, "--no-ff");
277
+ runArgsInherited("git", args);
278
+ }
279
+ function squashMergeBranch(cwd, branch) {
280
+ runArgsInherited("git", ["-C", cwd, "merge", "--squash", branch]);
281
+ }
282
+ function rebaseBranch(cwd, onto) {
283
+ runArgsInherited("git", ["-C", cwd, "rebase", onto]);
284
+ }
285
+ function ffMergeBranch(cwd, branch) {
286
+ runArgsInherited("git", ["-C", cwd, "merge", "--ff-only", branch]);
287
+ }
288
+ function commitMerge(cwd, message) {
289
+ runArgsInherited("git", ["-C", cwd, "commit", "-m", message]);
290
+ }
291
+ function renameBranch(oldName, newName) {
292
+ runArgsInherited("git", ["branch", "-m", oldName, newName]);
293
+ }
294
+ function moveWorktree(oldPath, newPath) {
295
+ runArgsInherited("git", ["worktree", "move", oldPath, newPath]);
296
+ }
226
297
 
227
298
  // src/core/config.ts
228
299
  var fs = __toESM(require("fs"));
@@ -333,7 +404,9 @@ async function ensureConfig() {
333
404
  // src/core/files.ts
334
405
  var fs2 = __toESM(require("fs"));
335
406
  var path2 = __toESM(require("path"));
407
+ var import_minimatch = require("minimatch");
336
408
  var GWITINCLUDE_FILE = ".gwitinclude";
409
+ var GLOB_CHARS = /[*?[]/;
337
410
  function _parseLines(content) {
338
411
  return content.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
339
412
  }
@@ -353,14 +426,33 @@ function copyEntry(src, dest) {
353
426
  fs2.copyFileSync(src, dest);
354
427
  }
355
428
  }
429
+ function isGlobPattern(entry) {
430
+ return GLOB_CHARS.test(entry);
431
+ }
432
+ function expandGlob(pattern, cwd) {
433
+ const result = runArgsSafe("git", ["ls-files", "--others", "--ignored", "--exclude-standard"], {
434
+ cwd
435
+ });
436
+ if (!result.success) return [];
437
+ const files = result.stdout.split("\n").filter((f) => f.length > 0);
438
+ return files.filter((f) => (0, import_minimatch.minimatch)(f, pattern, { dot: true }));
439
+ }
356
440
  function parseGwitInclude(mainPath) {
357
441
  const includePath = path2.join(mainPath, GWITINCLUDE_FILE);
358
442
  if (!fs2.existsSync(includePath)) return [];
359
443
  return _parseLines(fs2.readFileSync(includePath, "utf-8"));
360
444
  }
361
445
  function copyIncludedFiles(mainPath, worktreePath) {
362
- const entries = parseGwitInclude(mainPath);
363
- if (entries.length === 0) return [];
446
+ const rawEntries = parseGwitInclude(mainPath);
447
+ if (rawEntries.length === 0) return [];
448
+ const entries = [];
449
+ for (const entry of rawEntries) {
450
+ if (isGlobPattern(entry)) {
451
+ entries.push(...expandGlob(entry, mainPath));
452
+ } else {
453
+ entries.push(entry);
454
+ }
455
+ }
364
456
  const copied = [];
365
457
  for (const entry of entries) {
366
458
  const entryPath = entry.replace(/\/$/, "");
@@ -394,56 +486,247 @@ function copyIncludedFiles(mainPath, worktreePath) {
394
486
  }
395
487
  return copied;
396
488
  }
489
+ function reverseCopyIncludedFiles(worktreePath, mainPath) {
490
+ const rawEntries = parseGwitInclude(mainPath);
491
+ if (rawEntries.length === 0) return [];
492
+ const entries = [];
493
+ for (const entry of rawEntries) {
494
+ if (isGlobPattern(entry)) {
495
+ entries.push(...expandGlob(entry, worktreePath));
496
+ } else {
497
+ entries.push(entry);
498
+ }
499
+ }
500
+ const copied = [];
501
+ for (const entry of entries) {
502
+ const entryPath = entry.replace(/\/$/, "");
503
+ if (path2.isAbsolute(entryPath)) {
504
+ ui.dim(` skip ${entry} (absolute path not allowed)`);
505
+ continue;
506
+ }
507
+ const worktreeResolved = path2.resolve(worktreePath);
508
+ const resolvedSrc = path2.resolve(worktreePath, entryPath);
509
+ const rel = path2.relative(worktreeResolved, resolvedSrc);
510
+ if (rel.startsWith("..") || path2.isAbsolute(rel)) {
511
+ ui.dim(` skip ${entry} (path escapes repo)`);
512
+ continue;
513
+ }
514
+ const src = resolvedSrc;
515
+ const dest = path2.resolve(mainPath, entryPath);
516
+ if (!fs2.existsSync(src)) {
517
+ ui.dim(` skip ${entry} (not found in worktree)`);
518
+ continue;
519
+ }
520
+ if (!isGitIgnored(entryPath, mainPath)) {
521
+ ui.dim(` skip ${entry} (not gitignored)`);
522
+ continue;
523
+ }
524
+ if (isGitTracked(entryPath, mainPath)) {
525
+ ui.dim(` skip ${entry} (tracked by git)`);
526
+ continue;
527
+ }
528
+ copyEntry(src, dest);
529
+ copied.push(entry);
530
+ }
531
+ return copied;
532
+ }
533
+
534
+ // src/core/snapshot.ts
535
+ var fs3 = __toESM(require("fs"));
536
+ var path3 = __toESM(require("path"));
537
+ var import_crypto = require("crypto");
538
+ var FILES_DIR_NAME = "files";
539
+ var MANIFEST_FILE_NAME = "manifest.json";
540
+ function normalizeRelPath(relPath) {
541
+ return relPath.replace(/\\/g, "/");
542
+ }
543
+ function tryChmod(filePath, mode) {
544
+ try {
545
+ fs3.chmodSync(filePath, mode);
546
+ } catch {
547
+ }
548
+ }
549
+ function ensureDir(dirPath) {
550
+ fs3.mkdirSync(dirPath, { recursive: true, mode: 448 });
551
+ tryChmod(dirPath, 448);
552
+ }
553
+ function getManifestPath(slug) {
554
+ return path3.join(getSnapshotDir(slug), MANIFEST_FILE_NAME);
555
+ }
556
+ function getSnapshotFilesDir(slug) {
557
+ return path3.join(getSnapshotDir(slug), FILES_DIR_NAME);
558
+ }
559
+ function collectEntryFiles(entryPath, mainPath) {
560
+ const resolvedMain = path3.resolve(mainPath);
561
+ const resolvedEntry = path3.resolve(entryPath);
562
+ const relToMain = path3.relative(resolvedMain, resolvedEntry);
563
+ if (relToMain.startsWith("..") || path3.isAbsolute(relToMain)) {
564
+ return [];
565
+ }
566
+ const stat = fs3.statSync(resolvedEntry);
567
+ if (stat.isFile()) {
568
+ return [normalizeRelPath(relToMain)];
569
+ }
570
+ const files = [];
571
+ const stack = [resolvedEntry];
572
+ while (stack.length > 0) {
573
+ const current = stack.pop();
574
+ if (!current) continue;
575
+ const entries = fs3.readdirSync(current, { withFileTypes: true });
576
+ for (const entry of entries) {
577
+ const childPath = path3.join(current, entry.name);
578
+ if (entry.isDirectory()) {
579
+ stack.push(childPath);
580
+ continue;
581
+ }
582
+ if (entry.isFile()) {
583
+ const rel = path3.relative(resolvedMain, childPath);
584
+ if (!rel.startsWith("..") && !path3.isAbsolute(rel)) {
585
+ files.push(normalizeRelPath(rel));
586
+ }
587
+ }
588
+ }
589
+ }
590
+ return files;
591
+ }
592
+ function writeManifestAtomic(slug, manifest) {
593
+ const snapshotDir = getSnapshotDir(slug);
594
+ const manifestPath = getManifestPath(slug);
595
+ const tmpPath = path3.join(
596
+ snapshotDir,
597
+ `${MANIFEST_FILE_NAME}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`
598
+ );
599
+ fs3.writeFileSync(tmpPath, JSON.stringify(manifest, null, 2), "utf-8");
600
+ tryChmod(tmpPath, 384);
601
+ fs3.renameSync(tmpPath, manifestPath);
602
+ }
603
+ function hashFile(filePath) {
604
+ const data = fs3.readFileSync(filePath);
605
+ return (0, import_crypto.createHash)("sha256").update(data).digest("hex");
606
+ }
607
+ function createSnapshot(slug, branch, mainPath, copiedFiles) {
608
+ const snapshotsDir = getSnapshotsDir();
609
+ const snapshotDir = getSnapshotDir(slug);
610
+ const filesDir = getSnapshotFilesDir(slug);
611
+ ensureDir(snapshotsDir);
612
+ if (fs3.existsSync(snapshotDir)) {
613
+ fs3.rmSync(snapshotDir, { recursive: true, force: true });
614
+ }
615
+ ensureDir(snapshotDir);
616
+ ensureDir(filesDir);
617
+ const relPaths = /* @__PURE__ */ new Set();
618
+ for (const entry of copiedFiles) {
619
+ const entryPath = entry.replace(/\/$/, "");
620
+ if (entryPath.length === 0) continue;
621
+ const sourcePath = path3.resolve(mainPath, entryPath);
622
+ if (!fs3.existsSync(sourcePath)) continue;
623
+ for (const relPath of collectEntryFiles(sourcePath, mainPath)) {
624
+ relPaths.add(relPath);
625
+ }
626
+ }
627
+ const manifest = {
628
+ branch,
629
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
630
+ files: {}
631
+ };
632
+ for (const relPath of [...relPaths].sort()) {
633
+ const sourcePath = path3.resolve(mainPath, relPath);
634
+ if (!fs3.existsSync(sourcePath)) continue;
635
+ const destPath = path3.join(filesDir, relPath);
636
+ ensureDir(path3.dirname(destPath));
637
+ fs3.copyFileSync(sourcePath, destPath);
638
+ tryChmod(destPath, 384);
639
+ const stat = fs3.statSync(sourcePath);
640
+ manifest.files[relPath] = {
641
+ hash: hashFile(sourcePath),
642
+ size: stat.size
643
+ };
644
+ }
645
+ writeManifestAtomic(slug, manifest);
646
+ }
647
+ function readSnapshot(slug) {
648
+ const manifestPath = getManifestPath(slug);
649
+ if (!fs3.existsSync(manifestPath)) return void 0;
650
+ try {
651
+ return JSON.parse(fs3.readFileSync(manifestPath, "utf-8"));
652
+ } catch {
653
+ return void 0;
654
+ }
655
+ }
656
+ function getSnapshotFilePath(slug, relPath) {
657
+ const filesDir = path3.resolve(getSnapshotFilesDir(slug));
658
+ const targetPath = path3.resolve(filesDir, relPath);
659
+ const relToRoot = path3.relative(filesDir, targetPath);
660
+ if (relToRoot.startsWith("..") || path3.isAbsolute(relToRoot)) {
661
+ return void 0;
662
+ }
663
+ return fs3.existsSync(targetPath) ? targetPath : void 0;
664
+ }
665
+ function deleteSnapshot(slug) {
666
+ const snapshotDir = getSnapshotDir(slug);
667
+ if (!fs3.existsSync(snapshotDir)) return;
668
+ fs3.rmSync(snapshotDir, { recursive: true, force: true });
669
+ }
670
+ function renameSnapshot(oldSlug, newSlug) {
671
+ const oldDir = getSnapshotDir(oldSlug);
672
+ const newDir = getSnapshotDir(newSlug);
673
+ if (!fs3.existsSync(oldDir)) return;
674
+ ensureDir(getSnapshotsDir());
675
+ if (fs3.existsSync(newDir)) {
676
+ fs3.rmSync(newDir, { recursive: true, force: true });
677
+ }
678
+ fs3.renameSync(oldDir, newDir);
679
+ }
397
680
 
398
681
  // src/core/ports.ts
399
682
  var net = __toESM(require("net"));
400
683
 
401
684
  // src/core/registry.ts
402
- var fs3 = __toESM(require("fs"));
403
- var path3 = __toESM(require("path"));
685
+ var fs4 = __toESM(require("fs"));
686
+ var path4 = __toESM(require("path"));
404
687
  var MAX_RETRIES = 3;
405
688
  var RETRY_DELAY_MS = 100;
406
689
  function sleep(ms) {
407
- return new Promise((resolve2) => setTimeout(resolve2, ms));
690
+ return new Promise((resolve4) => setTimeout(resolve4, ms));
408
691
  }
409
692
  async function writeRegistry(registry) {
410
693
  const registryPath = getRegistryPath();
411
694
  const gwitDir = getGwitDir();
412
- if (!fs3.existsSync(gwitDir)) {
413
- fs3.mkdirSync(gwitDir, { recursive: true, mode: 448 });
695
+ if (!fs4.existsSync(gwitDir)) {
696
+ fs4.mkdirSync(gwitDir, { recursive: true, mode: 448 });
414
697
  }
415
- const tmpPath = path3.join(
698
+ const tmpPath = path4.join(
416
699
  gwitDir,
417
700
  `worktrees.json.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`
418
701
  );
419
- fs3.writeFileSync(tmpPath, JSON.stringify(registry, null, 2), "utf-8");
702
+ fs4.writeFileSync(tmpPath, JSON.stringify(registry, null, 2), "utf-8");
420
703
  try {
421
- fs3.chmodSync(tmpPath, 384);
704
+ fs4.chmodSync(tmpPath, 384);
422
705
  } catch {
423
706
  }
424
707
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
425
708
  try {
426
- fs3.renameSync(tmpPath, registryPath);
709
+ fs4.renameSync(tmpPath, registryPath);
427
710
  return;
428
711
  } catch {
429
712
  if (attempt < MAX_RETRIES) await sleep(RETRY_DELAY_MS);
430
713
  }
431
714
  }
432
715
  try {
433
- fs3.unlinkSync(tmpPath);
716
+ fs4.unlinkSync(tmpPath);
434
717
  } catch {
435
718
  }
436
719
  throw new GwitError("Failed to write worktree registry after multiple retries.");
437
720
  }
438
721
  function readRegistry() {
439
722
  const registryPath = getRegistryPath();
440
- if (!fs3.existsSync(registryPath)) return {};
723
+ if (!fs4.existsSync(registryPath)) return {};
441
724
  try {
442
- return JSON.parse(fs3.readFileSync(registryPath, "utf-8"));
725
+ return JSON.parse(fs4.readFileSync(registryPath, "utf-8"));
443
726
  } catch {
444
727
  const backupPath = `${registryPath}.bak`;
445
728
  try {
446
- fs3.copyFileSync(registryPath, backupPath);
729
+ fs4.copyFileSync(registryPath, backupPath);
447
730
  } catch {
448
731
  }
449
732
  console.warn(`\u26A0 Corrupted registry backed up to ${backupPath}. Starting fresh.`);
@@ -484,10 +767,10 @@ function listWorktreeEntries(mainPath) {
484
767
  // src/core/ports.ts
485
768
  var MAX_PORT_SCAN = 100;
486
769
  function _isPortFree(port) {
487
- return new Promise((resolve2) => {
770
+ return new Promise((resolve4) => {
488
771
  const server = net.createServer();
489
- server.once("error", () => server.close(() => resolve2(false)));
490
- server.once("listening", () => server.close(() => resolve2(true)));
772
+ server.once("error", () => server.close(() => resolve4(false)));
773
+ server.once("listening", () => server.close(() => resolve4(true)));
491
774
  server.listen(port, "127.0.0.1");
492
775
  });
493
776
  }
@@ -523,8 +806,8 @@ function buildEnvironment(branch, slug, port, worktreePath, mainPath, index) {
523
806
  }
524
807
 
525
808
  // src/core/hooks.ts
526
- var fs4 = __toESM(require("fs"));
527
- var path4 = __toESM(require("path"));
809
+ var fs5 = __toESM(require("fs"));
810
+ var path5 = __toESM(require("path"));
528
811
  var import_child_process2 = require("child_process");
529
812
  var GWITCOMMAND_FILE = ".gwitcommand";
530
813
  var GWITCLEANUP_FILE = ".gwitcleanup";
@@ -532,9 +815,9 @@ function _parseHookLines(content) {
532
815
  return content.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
533
816
  }
534
817
  function readHookFile(mainPath, filename) {
535
- const filePath = path4.join(mainPath, filename);
536
- if (!fs4.existsSync(filePath)) return [];
537
- return _parseHookLines(fs4.readFileSync(filePath, "utf-8"));
818
+ const filePath = path5.join(mainPath, filename);
819
+ if (!fs5.existsSync(filePath)) return [];
820
+ return _parseHookLines(fs5.readFileSync(filePath, "utf-8"));
538
821
  }
539
822
  function executeCommands(commands, worktreePath, env, stopOnError, label) {
540
823
  const mergedEnv = { ...process.env, ...env };
@@ -624,6 +907,10 @@ async function createCommand(branch, options) {
624
907
  );
625
908
  }
626
909
  assertBranchResolvable(branch, isNew);
910
+ if (!isNew && !branchExistsLocal(branch)) {
911
+ ui.step("Fetching from origin\u2026");
912
+ fetchOrigin();
913
+ }
627
914
  const config = await ensureConfig();
628
915
  const mainPath = getMainWorktreePath();
629
916
  const slug = toSlug(branch);
@@ -633,6 +920,7 @@ async function createCommand(branch, options) {
633
920
  ui.success(`Worktree ready at ${ui.bold(worktreePath)}`);
634
921
  const copied = copyIncludedFiles(mainPath, worktreePath);
635
922
  if (copied.length > 0) {
923
+ createSnapshot(slug, branch, mainPath, copied);
636
924
  ui.success(`Copied ${copied.length} file${copied.length === 1 ? "" : "s"} from .gwitinclude`);
637
925
  copied.forEach((f) => ui.dim(` ${f}`));
638
926
  }
@@ -696,7 +984,7 @@ function listCommand() {
696
984
  }
697
985
 
698
986
  // src/commands/remove.ts
699
- var fs5 = __toESM(require("fs"));
987
+ var fs6 = __toESM(require("fs"));
700
988
  function removeUnregisteredWorktree(branch, force) {
701
989
  const worktrees = listWorktrees();
702
990
  const match = worktrees.find((w) => w.branch === branch);
@@ -726,7 +1014,7 @@ async function removeCommand(branch, options) {
726
1014
  return;
727
1015
  }
728
1016
  const worktreePath = entry.path;
729
- if (!force && fs5.existsSync(worktreePath) && hasUncommittedChanges(worktreePath)) {
1017
+ if (!force && fs6.existsSync(worktreePath) && hasUncommittedChanges(worktreePath)) {
730
1018
  throw new GwitError(
731
1019
  `Worktree '${branch}' has uncommitted changes.`,
732
1020
  `Use 'gwit remove ${branch} --force' to remove anyway, or commit first.`
@@ -742,13 +1030,14 @@ async function removeCommand(branch, options) {
742
1030
  );
743
1031
  runCleanupHooks(mainPath, worktreePath, env);
744
1032
  ui.step(`Removing worktree at ${worktreePath}\u2026`);
745
- if (fs5.existsSync(worktreePath)) {
1033
+ if (fs6.existsSync(worktreePath)) {
746
1034
  removeWorktree(worktreePath, force);
747
1035
  } else {
748
1036
  runArgs("git", ["worktree", "prune"]);
749
1037
  ui.dim(" (directory not found \u2014 pruned git bookkeeping)");
750
1038
  }
751
1039
  await removeWorktreeEntry(mainPath, branch);
1040
+ deleteSnapshot(entry.slug);
752
1041
  ui.success(`Removed worktree for '${branch}' (port ${entry.port} freed)`);
753
1042
  }
754
1043
 
@@ -809,8 +1098,8 @@ function configSetCommand(key, value) {
809
1098
  }
810
1099
 
811
1100
  // src/commands/init.ts
812
- var fs6 = __toESM(require("fs"));
813
- var path5 = __toESM(require("path"));
1101
+ var fs7 = __toESM(require("fs"));
1102
+ var path6 = __toESM(require("path"));
814
1103
  var import_inquirer2 = __toESM(require("inquirer"));
815
1104
  var GWITINCLUDE_FILE2 = ".gwitinclude";
816
1105
  var GWITCOMMAND_FILE2 = ".gwitcommand";
@@ -839,23 +1128,23 @@ var CLEANUP_HEADER = [
839
1128
  "# All $GWIT_* variables are available."
840
1129
  ].join("\n");
841
1130
  function _detectAppName(mainPath) {
842
- const pkgPath = path5.join(mainPath, "package.json");
843
- if (fs6.existsSync(pkgPath)) {
1131
+ const pkgPath = path6.join(mainPath, "package.json");
1132
+ if (fs7.existsSync(pkgPath)) {
844
1133
  try {
845
- const pkg2 = JSON.parse(fs6.readFileSync(pkgPath, "utf-8"));
1134
+ const pkg2 = JSON.parse(fs7.readFileSync(pkgPath, "utf-8"));
846
1135
  if (pkg2.name && pkg2.name.trim().length > 0) {
847
1136
  return pkg2.name.replace(/^@[^/]+\//, "").trim();
848
1137
  }
849
1138
  } catch {
850
1139
  }
851
1140
  }
852
- return path5.basename(mainPath);
1141
+ return path6.basename(mainPath);
853
1142
  }
854
1143
  function _detectPackageManager(mainPath) {
855
- if (fs6.existsSync(path5.join(mainPath, "bun.lockb"))) return "bun";
856
- if (fs6.existsSync(path5.join(mainPath, "pnpm-lock.yaml"))) return "pnpm";
857
- if (fs6.existsSync(path5.join(mainPath, "yarn.lock"))) return "yarn";
858
- if (fs6.existsSync(path5.join(mainPath, "package.json"))) return "npm";
1144
+ if (fs7.existsSync(path6.join(mainPath, "bun.lockb"))) return "bun";
1145
+ if (fs7.existsSync(path6.join(mainPath, "pnpm-lock.yaml"))) return "pnpm";
1146
+ if (fs7.existsSync(path6.join(mainPath, "yarn.lock"))) return "yarn";
1147
+ if (fs7.existsSync(path6.join(mainPath, "package.json"))) return "npm";
859
1148
  return null;
860
1149
  }
861
1150
  function scanGitignored(mainPath) {
@@ -868,8 +1157,8 @@ function scanGitignored(mainPath) {
868
1157
  return result.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
869
1158
  }
870
1159
  function writeHookFile(filePath, content, force) {
871
- if (fs6.existsSync(filePath) && !force) return "skipped";
872
- fs6.writeFileSync(filePath, content, "utf-8");
1160
+ if (fs7.existsSync(filePath) && !force) return "skipped";
1161
+ fs7.writeFileSync(filePath, content, "utf-8");
873
1162
  return "written";
874
1163
  }
875
1164
  async function collectCommands(firstMessage, continueMessage) {
@@ -1074,7 +1363,7 @@ async function initCommand(options) {
1074
1363
  const detectedAppName = _detectAppName(mainPath);
1075
1364
  const detectedPm = _detectPackageManager(mainPath);
1076
1365
  console.log();
1077
- ui.info(`Setting up gwit for ${ui.bold(path5.basename(mainPath))}`);
1366
+ ui.info(`Setting up gwit for ${ui.bold(path6.basename(mainPath))}`);
1078
1367
  if (!options.force) {
1079
1368
  ui.dim(" Existing files are skipped \u2014 use --force to overwrite.");
1080
1369
  }
@@ -1091,7 +1380,7 @@ async function initCommand(options) {
1091
1380
  { name: GWITCLEANUP_FILE2, content: _buildCleanupContent(sel.appName, sel) }
1092
1381
  ];
1093
1382
  for (const { name, content } of files) {
1094
- const result = writeHookFile(path5.join(mainPath, name), content, options.force);
1383
+ const result = writeHookFile(path6.join(mainPath, name), content, options.force);
1095
1384
  if (result === "written") {
1096
1385
  ui.success(` ${name}`);
1097
1386
  } else {
@@ -1127,7 +1416,117 @@ function openCommand(branch, options) {
1127
1416
  }
1128
1417
 
1129
1418
  // src/commands/sync.ts
1130
- var fs7 = __toESM(require("fs"));
1419
+ var fs9 = __toESM(require("fs"));
1420
+
1421
+ // src/core/merge.ts
1422
+ var fs8 = __toESM(require("fs"));
1423
+ var path7 = __toESM(require("path"));
1424
+ function normalizeRelPath2(relPath) {
1425
+ return relPath.replace(/\\/g, "/");
1426
+ }
1427
+ function copyFileToMain(worktreeFile, mainFile) {
1428
+ fs8.mkdirSync(path7.dirname(mainFile), { recursive: true });
1429
+ fs8.copyFileSync(worktreeFile, mainFile);
1430
+ }
1431
+ function isExistingFile(filePath) {
1432
+ return fs8.existsSync(filePath) && fs8.statSync(filePath).isFile();
1433
+ }
1434
+ function isBinaryFile(filePath) {
1435
+ const fd = fs8.openSync(filePath, "r");
1436
+ const buffer = Buffer.alloc(8192);
1437
+ try {
1438
+ const bytesRead = fs8.readSync(fd, buffer, 0, buffer.length, 0);
1439
+ for (let i = 0; i < bytesRead; i++) {
1440
+ if (buffer[i] === 0) return true;
1441
+ }
1442
+ return false;
1443
+ } finally {
1444
+ fs8.closeSync(fd);
1445
+ }
1446
+ }
1447
+ function runGitMergeFile(mainFile, snapshotFile, worktreeFile, branch) {
1448
+ const result = runArgsWithExitCode("git", [
1449
+ "merge-file",
1450
+ "-L",
1451
+ "main",
1452
+ "-L",
1453
+ "base",
1454
+ "-L",
1455
+ branch,
1456
+ mainFile,
1457
+ snapshotFile,
1458
+ worktreeFile
1459
+ ]);
1460
+ return result.exitCode > 0;
1461
+ }
1462
+ function mergeBackIncludedFiles(worktreePath, mainPath, slug) {
1463
+ const result = {
1464
+ copied: [],
1465
+ merged: [],
1466
+ conflicts: [],
1467
+ skipped: [],
1468
+ binarySkipped: []
1469
+ };
1470
+ const manifest = readSnapshot(slug);
1471
+ if (!manifest) return result;
1472
+ const relPaths = Object.keys(manifest.files).sort();
1473
+ for (const rawRelPath of relPaths) {
1474
+ const relPath = normalizeRelPath2(rawRelPath);
1475
+ const base = manifest.files[relPath];
1476
+ if (!base) {
1477
+ result.skipped.push(relPath);
1478
+ continue;
1479
+ }
1480
+ if (!isGitIgnored(relPath, mainPath) || isGitTracked(relPath, mainPath)) {
1481
+ result.skipped.push(relPath);
1482
+ continue;
1483
+ }
1484
+ const mainFile = path7.resolve(mainPath, relPath);
1485
+ const worktreeFile = path7.resolve(worktreePath, relPath);
1486
+ const snapshotFile = getSnapshotFilePath(slug, relPath);
1487
+ if (!isExistingFile(mainFile)) {
1488
+ result.skipped.push(relPath);
1489
+ continue;
1490
+ }
1491
+ if (!isExistingFile(worktreeFile)) {
1492
+ result.skipped.push(relPath);
1493
+ continue;
1494
+ }
1495
+ if (!snapshotFile || !isExistingFile(snapshotFile)) {
1496
+ result.skipped.push(relPath);
1497
+ continue;
1498
+ }
1499
+ const baseHash = base.hash;
1500
+ const mainHash = hashFile(mainFile);
1501
+ const worktreeHash = hashFile(worktreeFile);
1502
+ if (baseHash === mainHash && baseHash === worktreeHash) {
1503
+ result.skipped.push(relPath);
1504
+ continue;
1505
+ }
1506
+ if (baseHash === mainHash) {
1507
+ copyFileToMain(worktreeFile, mainFile);
1508
+ result.copied.push(relPath);
1509
+ continue;
1510
+ }
1511
+ if (baseHash === worktreeHash || mainHash === worktreeHash) {
1512
+ result.skipped.push(relPath);
1513
+ continue;
1514
+ }
1515
+ if (isBinaryFile(mainFile) || isBinaryFile(worktreeFile) || isBinaryFile(snapshotFile)) {
1516
+ result.binarySkipped.push(relPath);
1517
+ continue;
1518
+ }
1519
+ const hasConflicts = runGitMergeFile(mainFile, snapshotFile, worktreeFile, manifest.branch);
1520
+ if (hasConflicts) {
1521
+ result.conflicts.push(relPath);
1522
+ } else {
1523
+ result.merged.push(relPath);
1524
+ }
1525
+ }
1526
+ return result;
1527
+ }
1528
+
1529
+ // src/commands/sync.ts
1131
1530
  function detectCurrentBranch(mainPath) {
1132
1531
  const currentRoot = getRepoRoot();
1133
1532
  if (currentRoot === mainPath) {
@@ -1149,7 +1548,45 @@ function detectCurrentBranch(mainPath) {
1149
1548
  }
1150
1549
  return current.branch;
1151
1550
  }
1152
- function syncCommand(branch) {
1551
+ function logFileList(files) {
1552
+ files.forEach((f) => ui.dim(` ${f}`));
1553
+ }
1554
+ function reportBackResult(result) {
1555
+ if (result.copied.length > 0) {
1556
+ ui.success(
1557
+ `Copied ${result.copied.length} worktree-only file${result.copied.length === 1 ? "" : "s"}`
1558
+ );
1559
+ logFileList(result.copied);
1560
+ }
1561
+ if (result.merged.length > 0) {
1562
+ ui.success(
1563
+ `Merged ${result.merged.length} file${result.merged.length === 1 ? "" : "s"} cleanly`
1564
+ );
1565
+ logFileList(result.merged);
1566
+ }
1567
+ if (result.conflicts.length > 0) {
1568
+ ui.warn(
1569
+ `Conflicts in ${result.conflicts.length} file${result.conflicts.length === 1 ? "" : "s"} (markers written to main)`
1570
+ );
1571
+ logFileList(result.conflicts);
1572
+ }
1573
+ if (result.binarySkipped.length > 0) {
1574
+ ui.warn(
1575
+ `Skipped ${result.binarySkipped.length} binary file${result.binarySkipped.length === 1 ? "" : "s"} (manual resolution required)`
1576
+ );
1577
+ logFileList(result.binarySkipped);
1578
+ }
1579
+ if (result.skipped.length > 0) {
1580
+ ui.dim(
1581
+ ` skipped ${result.skipped.length} unchanged/main-only file${result.skipped.length === 1 ? "" : "s"}`
1582
+ );
1583
+ }
1584
+ const changed = result.copied.length + result.merged.length + result.conflicts.length;
1585
+ if (changed === 0 && result.binarySkipped.length === 0) {
1586
+ ui.info("Nothing to sync back \u2014 no snapshot-tracked files changed.");
1587
+ }
1588
+ }
1589
+ function syncCommand(branch, options = {}) {
1153
1590
  if (!isGitRepo()) {
1154
1591
  throw new GwitError(
1155
1592
  "Not a git repository.",
@@ -1165,26 +1602,403 @@ function syncCommand(branch) {
1165
1602
  `Run 'gwit list' to see active worktrees.`
1166
1603
  );
1167
1604
  }
1168
- if (!fs7.existsSync(entry.path)) {
1605
+ if (!fs9.existsSync(entry.path)) {
1169
1606
  throw new GwitError(
1170
1607
  `Worktree path no longer exists: ${entry.path}`,
1171
1608
  `Run 'gwit remove ${targetBranch}' to clean up the stale registry entry.`
1172
1609
  );
1173
1610
  }
1611
+ if (options.back) {
1612
+ ui.step(`Syncing .gwitinclude back to main from ${entry.path}\u2026`);
1613
+ const snapshot = readSnapshot(entry.slug);
1614
+ if (!snapshot) {
1615
+ ui.warn(`No snapshot found for '${targetBranch}' \u2014 falling back to direct copy.`);
1616
+ const copiedBack = reverseCopyIncludedFiles(entry.path, mainPath);
1617
+ if (copiedBack.length > 0) {
1618
+ ui.success(`Synced ${copiedBack.length} file${copiedBack.length === 1 ? "" : "s"} back`);
1619
+ logFileList(copiedBack);
1620
+ } else {
1621
+ ui.info("Nothing to sync back \u2014 no .gwitinclude entries were copied.");
1622
+ }
1623
+ return;
1624
+ }
1625
+ const result = mergeBackIncludedFiles(entry.path, mainPath, entry.slug);
1626
+ reportBackResult(result);
1627
+ return;
1628
+ }
1174
1629
  ui.step(`Syncing .gwitinclude into ${entry.path}\u2026`);
1175
1630
  const copied = copyIncludedFiles(mainPath, entry.path);
1176
1631
  if (copied.length > 0) {
1177
1632
  ui.success(`Synced ${copied.length} file${copied.length === 1 ? "" : "s"}`);
1178
- copied.forEach((f) => ui.dim(` ${f}`));
1633
+ logFileList(copied);
1179
1634
  } else {
1180
1635
  ui.info("Nothing to sync \u2014 no .gwitinclude entries were copied.");
1181
1636
  }
1182
1637
  }
1183
1638
 
1639
+ // src/commands/merge.ts
1640
+ var fs10 = __toESM(require("fs"));
1641
+ function executeMerge(mainPath, worktreePath, branch, target, options) {
1642
+ try {
1643
+ if (options.squash) {
1644
+ ui.step(`Squash merging '${branch}' into '${target}'\u2026`);
1645
+ squashMergeBranch(mainPath, branch);
1646
+ commitMerge(mainPath, `Squash merge branch '${branch}'`);
1647
+ } else if (options.rebase) {
1648
+ ui.step(`Rebasing '${branch}' onto '${target}'\u2026`);
1649
+ rebaseBranch(worktreePath, target);
1650
+ ui.step(`Fast-forwarding '${target}' to '${branch}'\u2026`);
1651
+ ffMergeBranch(mainPath, branch);
1652
+ } else {
1653
+ ui.step(`Merging '${branch}' into '${target}'\u2026`);
1654
+ mergeBranch(mainPath, branch, options.noFf ?? false);
1655
+ }
1656
+ } catch {
1657
+ throw new GwitError(
1658
+ `Merge conflict while merging '${branch}' into '${target}'.`,
1659
+ `Resolve conflicts in ${mainPath}, then run: git merge --continue`
1660
+ );
1661
+ }
1662
+ }
1663
+ async function cleanupWorktree(mainPath, worktreePath, branch, port) {
1664
+ const entry = getWorktreeEntry(mainPath, branch);
1665
+ if (entry) {
1666
+ const env = buildEnvironment(
1667
+ entry.branch,
1668
+ entry.slug,
1669
+ entry.port,
1670
+ worktreePath,
1671
+ mainPath,
1672
+ entry.index
1673
+ );
1674
+ runCleanupHooks(mainPath, worktreePath, env);
1675
+ }
1676
+ ui.step(`Removing worktree at ${worktreePath}\u2026`);
1677
+ if (fs10.existsSync(worktreePath)) {
1678
+ removeWorktree(worktreePath, true);
1679
+ } else {
1680
+ runArgs("git", ["worktree", "prune"]);
1681
+ }
1682
+ await removeWorktreeEntry(mainPath, branch);
1683
+ ui.success(`Cleaned up worktree for '${branch}' (port ${port} freed)`);
1684
+ }
1685
+ function syncBackFiles(worktreePath, mainPath, slug, branch) {
1686
+ const snapshot = readSnapshot(slug);
1687
+ if (!snapshot) {
1688
+ ui.warn(`No snapshot found for '${branch}' \u2014 falling back to direct copy.`);
1689
+ const copied = reverseCopyIncludedFiles(worktreePath, mainPath);
1690
+ if (copied.length > 0) {
1691
+ ui.success(`Synced ${copied.length} file${copied.length === 1 ? "" : "s"} back`);
1692
+ copied.forEach((f) => ui.dim(` ${f}`));
1693
+ } else {
1694
+ ui.dim(" No .gwitinclude files to sync back");
1695
+ }
1696
+ return;
1697
+ }
1698
+ const result = mergeBackIncludedFiles(worktreePath, mainPath, slug);
1699
+ const changed = result.copied.length + result.merged.length + result.conflicts.length;
1700
+ if (result.copied.length > 0) {
1701
+ ui.success(
1702
+ `Copied ${result.copied.length} worktree-only file${result.copied.length === 1 ? "" : "s"}`
1703
+ );
1704
+ result.copied.forEach((f) => ui.dim(` ${f}`));
1705
+ }
1706
+ if (result.merged.length > 0) {
1707
+ ui.success(
1708
+ `Merged ${result.merged.length} file${result.merged.length === 1 ? "" : "s"} cleanly`
1709
+ );
1710
+ result.merged.forEach((f) => ui.dim(` ${f}`));
1711
+ }
1712
+ if (result.conflicts.length > 0) {
1713
+ ui.warn(
1714
+ `Conflicts in ${result.conflicts.length} file${result.conflicts.length === 1 ? "" : "s"} (markers written to main)`
1715
+ );
1716
+ result.conflicts.forEach((f) => ui.dim(` ${f}`));
1717
+ }
1718
+ if (result.binarySkipped.length > 0) {
1719
+ ui.warn(
1720
+ `Skipped ${result.binarySkipped.length} binary file${result.binarySkipped.length === 1 ? "" : "s"} (manual resolution required)`
1721
+ );
1722
+ result.binarySkipped.forEach((f) => ui.dim(` ${f}`));
1723
+ }
1724
+ if (changed === 0 && result.binarySkipped.length === 0) {
1725
+ ui.dim(" No .gwitinclude files to sync back");
1726
+ }
1727
+ }
1728
+ async function mergeCommand(branch, options) {
1729
+ if (!isGitRepo()) {
1730
+ throw new GwitError(
1731
+ "Not a git repository.",
1732
+ "Run gwit merge from inside a git repo (or any of its worktrees)."
1733
+ );
1734
+ }
1735
+ const mainPath = getMainWorktreePath();
1736
+ const entry = getWorktreeEntry(mainPath, branch);
1737
+ if (!entry) {
1738
+ throw new GwitError(
1739
+ `No gwit worktree found for '${branch}'.`,
1740
+ `Run 'gwit list' to see active worktrees.`
1741
+ );
1742
+ }
1743
+ if (!fs10.existsSync(entry.path)) {
1744
+ throw new GwitError(
1745
+ `Worktree path no longer exists: ${entry.path}`,
1746
+ `Run 'gwit remove ${branch}' to clean up the stale registry entry.`
1747
+ );
1748
+ }
1749
+ if (hasUncommittedChanges(entry.path)) {
1750
+ throw new GwitError(
1751
+ `Worktree for '${branch}' has uncommitted changes.`,
1752
+ "Commit or stash changes in the worktree first, then retry."
1753
+ );
1754
+ }
1755
+ const target = options.into ?? getDefaultBranch();
1756
+ ui.info(`Target branch: ${ui.bold(target)}`);
1757
+ if (options.syncBack !== false) {
1758
+ ui.step("Syncing .gwitinclude files back to main\u2026");
1759
+ syncBackFiles(entry.path, mainPath, entry.slug, entry.branch);
1760
+ }
1761
+ executeMerge(mainPath, entry.path, branch, target, options);
1762
+ ui.success(`Merged '${branch}' into '${target}'`);
1763
+ if (options.cleanup) {
1764
+ await cleanupWorktree(mainPath, entry.path, branch, entry.port);
1765
+ }
1766
+ }
1767
+
1768
+ // src/commands/status.ts
1769
+ var fs11 = __toESM(require("fs"));
1770
+
1771
+ // src/core/gh.ts
1772
+ function aggregateChecks(rollup) {
1773
+ if (!rollup || rollup.length === 0) return null;
1774
+ let hasPending = false;
1775
+ for (const check of rollup) {
1776
+ const conclusion = check.conclusion ?? check.state;
1777
+ if (conclusion === "FAILURE" || conclusion === "ERROR" || conclusion === "ACTION_REQUIRED") {
1778
+ return "fail";
1779
+ }
1780
+ if (conclusion === "PENDING" || check.status === "IN_PROGRESS" || check.status === "QUEUED") {
1781
+ hasPending = true;
1782
+ }
1783
+ }
1784
+ return hasPending ? "pending" : "pass";
1785
+ }
1786
+ function isGhAvailable() {
1787
+ return runArgsSafe("gh", ["--version"]).success;
1788
+ }
1789
+ function getPrInfo(branch) {
1790
+ const result = runArgsSafe("gh", [
1791
+ "pr",
1792
+ "view",
1793
+ branch,
1794
+ "--json",
1795
+ "number,state,isDraft,statusCheckRollup"
1796
+ ]);
1797
+ if (!result.success || result.stdout.length === 0) return null;
1798
+ try {
1799
+ const data = JSON.parse(result.stdout);
1800
+ if (typeof data.number !== "number" || typeof data.state !== "string") return null;
1801
+ return {
1802
+ number: data.number,
1803
+ state: data.state,
1804
+ isDraft: data.isDraft ?? false,
1805
+ checksStatus: aggregateChecks(data.statusCheckRollup)
1806
+ };
1807
+ } catch {
1808
+ return null;
1809
+ }
1810
+ }
1811
+
1812
+ // src/commands/status.ts
1813
+ function buildRow(branch, entryPath, port, defaultBranch, ghAvailable) {
1814
+ const exists = fs11.existsSync(entryPath);
1815
+ const { ahead, behind } = exists ? getAheadBehind(branch, defaultBranch, entryPath) : { ahead: 0, behind: 0 };
1816
+ const dirty = exists ? hasUncommittedChanges(entryPath) : false;
1817
+ let pr = "\u2014";
1818
+ if (ghAvailable) {
1819
+ const info = getPrInfo(branch);
1820
+ if (info) {
1821
+ const draft = info.isDraft ? " draft" : "";
1822
+ const checks = info.checksStatus ? ` (checks: ${info.checksStatus})` : "";
1823
+ pr = `#${info.number} ${info.state.toLowerCase()}${draft}${checks}`;
1824
+ }
1825
+ }
1826
+ return { branch, path: entryPath, port, ahead, behind, dirty, pr };
1827
+ }
1828
+ function printTable2(rows) {
1829
+ const headers = ["Branch", "Port", "Ahead/Behind", "Changes", "PR"];
1830
+ const formatted = rows.map((r) => [
1831
+ r.branch,
1832
+ String(r.port),
1833
+ `+${r.ahead} / -${r.behind}`,
1834
+ r.dirty ? "dirty" : "clean",
1835
+ r.pr
1836
+ ]);
1837
+ const widths = headers.map(
1838
+ (h, i) => Math.max(h.length, ...formatted.map((row) => (row[i] ?? "").length))
1839
+ );
1840
+ const headerLine = headers.map((h, i) => h.padEnd(widths[i] ?? 0)).join(" ");
1841
+ console.log(` ${ui.bold(headerLine)}`);
1842
+ for (const row of formatted) {
1843
+ const line = row.map((cell, i) => (cell ?? "").padEnd(widths[i] ?? 0)).join(" ");
1844
+ console.log(` ${line}`);
1845
+ }
1846
+ }
1847
+ function statusCommand(options) {
1848
+ if (!isGitRepo()) {
1849
+ throw new GwitError(
1850
+ "Not a git repository.",
1851
+ "Run gwit status from inside a git repo (or any of its worktrees)."
1852
+ );
1853
+ }
1854
+ const mainPath = getMainWorktreePath();
1855
+ const entries = listWorktreeEntries(mainPath);
1856
+ if (entries.length === 0) {
1857
+ ui.info("No active gwit worktrees.");
1858
+ return;
1859
+ }
1860
+ const defaultBranch = getDefaultBranch();
1861
+ const ghAvail = isGhAvailable();
1862
+ const rows = entries.map((e) => buildRow(e.branch, e.path, e.port, defaultBranch, ghAvail));
1863
+ if (options.json) {
1864
+ console.log(JSON.stringify(rows, null, 2));
1865
+ return;
1866
+ }
1867
+ console.log();
1868
+ printTable2(rows);
1869
+ console.log();
1870
+ }
1871
+
1872
+ // src/commands/sweep.ts
1873
+ var fs12 = __toESM(require("fs"));
1874
+ var import_inquirer3 = __toESM(require("inquirer"));
1875
+ function isSweepable(branch, defaultBranch, ghAvailable) {
1876
+ if (isBranchMerged(branch, defaultBranch)) return true;
1877
+ if (ghAvailable) {
1878
+ const pr = getPrInfo(branch);
1879
+ if (pr && (pr.state === "MERGED" || pr.state === "CLOSED")) return true;
1880
+ }
1881
+ return false;
1882
+ }
1883
+ async function sweepEntry(entry, mainPath) {
1884
+ const env = buildEnvironment(
1885
+ entry.branch,
1886
+ entry.slug,
1887
+ entry.port,
1888
+ entry.path,
1889
+ mainPath,
1890
+ entry.index
1891
+ );
1892
+ runCleanupHooks(mainPath, entry.path, env);
1893
+ if (fs12.existsSync(entry.path)) {
1894
+ removeWorktree(entry.path, true);
1895
+ } else {
1896
+ runArgs("git", ["worktree", "prune"]);
1897
+ }
1898
+ await removeWorktreeEntry(mainPath, entry.branch);
1899
+ ui.success(`Removed '${entry.branch}' (port ${entry.port} freed)`);
1900
+ }
1901
+ async function sweepCommand(options) {
1902
+ if (!isGitRepo()) {
1903
+ throw new GwitError(
1904
+ "Not a git repository.",
1905
+ "Run gwit sweep from inside a git repo (or any of its worktrees)."
1906
+ );
1907
+ }
1908
+ const mainPath = getMainWorktreePath();
1909
+ const entries = listWorktreeEntries(mainPath);
1910
+ if (entries.length === 0) {
1911
+ ui.info("No active gwit worktrees to sweep.");
1912
+ return;
1913
+ }
1914
+ const defaultBranch = getDefaultBranch();
1915
+ const ghAvail = isGhAvailable();
1916
+ const sweepable = entries.filter((e) => isSweepable(e.branch, defaultBranch, ghAvail));
1917
+ if (sweepable.length === 0) {
1918
+ ui.info("No merged worktrees found to sweep.");
1919
+ return;
1920
+ }
1921
+ console.log();
1922
+ ui.info(`Found ${sweepable.length} merged worktree${sweepable.length === 1 ? "" : "s"}:`);
1923
+ for (const entry of sweepable) {
1924
+ ui.dim(` ${entry.branch} (port ${entry.port})`);
1925
+ }
1926
+ console.log();
1927
+ if (options.dryRun) {
1928
+ ui.info("Dry run \u2014 no worktrees were removed.");
1929
+ return;
1930
+ }
1931
+ if (!options.force) {
1932
+ const { confirm } = await import_inquirer3.default.prompt([
1933
+ {
1934
+ type: "confirm",
1935
+ name: "confirm",
1936
+ message: `Remove ${sweepable.length} worktree${sweepable.length === 1 ? "" : "s"}?`,
1937
+ default: false
1938
+ }
1939
+ ]);
1940
+ if (!confirm) {
1941
+ ui.info("Sweep cancelled.");
1942
+ return;
1943
+ }
1944
+ }
1945
+ for (const entry of sweepable) {
1946
+ await sweepEntry(entry, mainPath);
1947
+ }
1948
+ ui.success(`Swept ${sweepable.length} worktree${sweepable.length === 1 ? "" : "s"}`);
1949
+ }
1950
+
1951
+ // src/commands/rename.ts
1952
+ async function renameCommand(oldBranch, newBranch) {
1953
+ if (!isGitRepo()) {
1954
+ throw new GwitError(
1955
+ "Not a git repository.",
1956
+ "Run gwit rename from inside a git repo (or any of its worktrees)."
1957
+ );
1958
+ }
1959
+ const mainPath = getMainWorktreePath();
1960
+ const entry = getWorktreeEntry(mainPath, oldBranch);
1961
+ if (!entry) {
1962
+ throw new GwitError(
1963
+ `No gwit worktree found for '${oldBranch}'.`,
1964
+ `Run 'gwit list' to see active worktrees.`
1965
+ );
1966
+ }
1967
+ const existingNew = getWorktreeEntry(mainPath, newBranch);
1968
+ if (existingNew) {
1969
+ throw new GwitError(
1970
+ `A worktree already exists for '${newBranch}'.`,
1971
+ `Remove it first: gwit remove ${newBranch}`
1972
+ );
1973
+ }
1974
+ ui.step(`Renaming branch '${oldBranch}' \u2192 '${newBranch}'\u2026`);
1975
+ renameBranch(oldBranch, newBranch);
1976
+ const oldSlug = entry.slug;
1977
+ const newSlug = toSlug(newBranch);
1978
+ let newPath = entry.path;
1979
+ if (oldSlug !== newSlug) {
1980
+ const config = loadConfig();
1981
+ newPath = getWorktreePath(mainPath, config.location, newSlug);
1982
+ ui.step(`Moving worktree to ${newPath}\u2026`);
1983
+ moveWorktree(entry.path, newPath);
1984
+ renameSnapshot(oldSlug, newSlug);
1985
+ }
1986
+ await removeWorktreeEntry(mainPath, oldBranch);
1987
+ await addWorktreeEntry(mainPath, {
1988
+ ...entry,
1989
+ branch: newBranch,
1990
+ slug: newSlug,
1991
+ path: newPath
1992
+ });
1993
+ ui.success(`Renamed '${oldBranch}' \u2192 '${newBranch}'`);
1994
+ if (oldSlug !== newSlug) {
1995
+ ui.info(`Slug ${ui.bold(oldSlug)} \u2192 ${ui.bold(newSlug)}`);
1996
+ ui.info(`Path ${ui.bold(newPath)}`);
1997
+ }
1998
+ }
1999
+
1184
2000
  // src/index.ts
1185
- var pkg = JSON.parse(
1186
- fs8.readFileSync(path6.join(__dirname, "..", "package.json"), "utf-8")
1187
- );
2001
+ var pkg = JSON.parse(fs13.readFileSync(path8.join(__dirname, "..", "package.json"), "utf-8"));
1188
2002
  var program = new import_commander.Command();
1189
2003
  program.name("gwit").description("Fully isolated git worktrees for parallel development").version(pkg.version);
1190
2004
  program.argument("[branch]", "Branch name (local or remote)").option("-b", "Create a new branch from HEAD").option("--editor <name>", "Override editor for this invocation").option("--no-editor", "Skip opening editor").option("--no-commands", "Skip .gwitcommand execution").action(async (branch, options) => {
@@ -1216,8 +2030,20 @@ configCmd.action(() => {
1216
2030
  program.command("open <branch>").description("Re-open the editor for an existing gwit worktree").option("--editor <name>", "Override editor for this invocation").action((branch, options) => {
1217
2031
  openCommand(branch, options);
1218
2032
  });
1219
- program.command("sync [branch]").description("Re-copy .gwitinclude files into an existing worktree").action((branch) => {
1220
- syncCommand(branch);
2033
+ program.command("sync [branch]").description("Sync .gwitinclude files into a worktree or back to main").option("--back", "Three-way merge .gwitinclude files back to main").action((branch, options) => {
2034
+ syncCommand(branch, options);
2035
+ });
2036
+ program.command("merge <branch>").description("Merge a worktree branch back into the target branch").option("--into <target>", "Target branch (default: repo default branch)").option("--squash", "Squash all commits into one before merging").option("--rebase", "Rebase feature onto target, then fast-forward").option("--no-ff", "Force a merge commit even when fast-forward is possible").option("--cleanup", "Remove worktree after successful merge").option("--no-sync-back", "Skip reverse-copying .gwitinclude files").action(async (branch, options) => {
2037
+ await mergeCommand(branch, options);
2038
+ });
2039
+ program.command("status").description("Show status of all active gwit worktrees").option("--json", "Output as JSON").action((options) => {
2040
+ statusCommand(options);
2041
+ });
2042
+ program.command("sweep").description("Remove worktrees whose branches are merged").option("--dry-run", "Show what would be removed without removing").option("--force", "Skip confirmation prompt").action(async (options) => {
2043
+ await sweepCommand(options);
2044
+ });
2045
+ program.command("rename <old-branch> <new-branch>").description("Rename a worktree branch").action(async (oldBranch, newBranch) => {
2046
+ await renameCommand(oldBranch, newBranch);
1221
2047
  });
1222
2048
  program.parseAsync(process.argv).catch((err) => {
1223
2049
  if (err instanceof GwitError) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivanshshrivas/gwit",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Fully isolated git worktrees for parallel development",
5
5
  "license": "MIT",
6
6
  "engines": {
@@ -21,22 +21,30 @@
21
21
  "test:unit": "vitest run tests/unit",
22
22
  "test:integration": "vitest run tests/integration",
23
23
  "lint": "eslint src tests --ext .ts",
24
- "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\""
24
+ "lint:maintainability": "eslint -c eslint.maintainability.cjs src/",
25
+ "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"",
26
+ "format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\""
25
27
  },
26
28
  "dependencies": {
27
29
  "commander": "^12.1.0",
28
- "inquirer": "^8.2.6"
30
+ "inquirer": "^8.2.6",
31
+ "minimatch": "^10.2.4"
29
32
  },
30
33
  "devDependencies": {
34
+ "@eslint/js": "^9.39.3",
31
35
  "@types/inquirer": "^8.2.10",
36
+ "@types/minimatch": "^5.1.2",
32
37
  "@types/node": "^20.0.0",
33
38
  "@typescript-eslint/eslint-plugin": "^7.0.0",
34
39
  "@typescript-eslint/parser": "^7.0.0",
35
40
  "eslint": "^8.57.0",
41
+ "eslint-config-prettier": "^10.1.8",
42
+ "eslint-plugin-jsdoc": "^61.7.1",
36
43
  "prettier": "^3.2.0",
37
44
  "tsup": "^8.0.0",
38
45
  "tsx": "^4.7.0",
39
46
  "typescript": "^5.4.0",
47
+ "typescript-eslint": "^8.56.1",
40
48
  "vitest": "^1.6.0"
41
49
  }
42
50
  }