@shivanshshrivas/gwit 0.1.3 → 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 +880 -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() {
@@ -226,6 +244,56 @@ function hasUncommittedChanges(worktreePath) {
226
244
  const result = runArgsSafe("git", ["-C", worktreePath, "status", "--porcelain"]);
227
245
  return result.success && result.stdout.length > 0;
228
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
+ }
229
297
 
230
298
  // src/core/config.ts
231
299
  var fs = __toESM(require("fs"));
@@ -336,7 +404,9 @@ async function ensureConfig() {
336
404
  // src/core/files.ts
337
405
  var fs2 = __toESM(require("fs"));
338
406
  var path2 = __toESM(require("path"));
407
+ var import_minimatch = require("minimatch");
339
408
  var GWITINCLUDE_FILE = ".gwitinclude";
409
+ var GLOB_CHARS = /[*?[]/;
340
410
  function _parseLines(content) {
341
411
  return content.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
342
412
  }
@@ -356,14 +426,33 @@ function copyEntry(src, dest) {
356
426
  fs2.copyFileSync(src, dest);
357
427
  }
358
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
+ }
359
440
  function parseGwitInclude(mainPath) {
360
441
  const includePath = path2.join(mainPath, GWITINCLUDE_FILE);
361
442
  if (!fs2.existsSync(includePath)) return [];
362
443
  return _parseLines(fs2.readFileSync(includePath, "utf-8"));
363
444
  }
364
445
  function copyIncludedFiles(mainPath, worktreePath) {
365
- const entries = parseGwitInclude(mainPath);
366
- 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
+ }
367
456
  const copied = [];
368
457
  for (const entry of entries) {
369
458
  const entryPath = entry.replace(/\/$/, "");
@@ -397,56 +486,247 @@ function copyIncludedFiles(mainPath, worktreePath) {
397
486
  }
398
487
  return copied;
399
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
+ }
400
680
 
401
681
  // src/core/ports.ts
402
682
  var net = __toESM(require("net"));
403
683
 
404
684
  // src/core/registry.ts
405
- var fs3 = __toESM(require("fs"));
406
- var path3 = __toESM(require("path"));
685
+ var fs4 = __toESM(require("fs"));
686
+ var path4 = __toESM(require("path"));
407
687
  var MAX_RETRIES = 3;
408
688
  var RETRY_DELAY_MS = 100;
409
689
  function sleep(ms) {
410
- return new Promise((resolve2) => setTimeout(resolve2, ms));
690
+ return new Promise((resolve4) => setTimeout(resolve4, ms));
411
691
  }
412
692
  async function writeRegistry(registry) {
413
693
  const registryPath = getRegistryPath();
414
694
  const gwitDir = getGwitDir();
415
- if (!fs3.existsSync(gwitDir)) {
416
- fs3.mkdirSync(gwitDir, { recursive: true, mode: 448 });
695
+ if (!fs4.existsSync(gwitDir)) {
696
+ fs4.mkdirSync(gwitDir, { recursive: true, mode: 448 });
417
697
  }
418
- const tmpPath = path3.join(
698
+ const tmpPath = path4.join(
419
699
  gwitDir,
420
700
  `worktrees.json.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`
421
701
  );
422
- fs3.writeFileSync(tmpPath, JSON.stringify(registry, null, 2), "utf-8");
702
+ fs4.writeFileSync(tmpPath, JSON.stringify(registry, null, 2), "utf-8");
423
703
  try {
424
- fs3.chmodSync(tmpPath, 384);
704
+ fs4.chmodSync(tmpPath, 384);
425
705
  } catch {
426
706
  }
427
707
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
428
708
  try {
429
- fs3.renameSync(tmpPath, registryPath);
709
+ fs4.renameSync(tmpPath, registryPath);
430
710
  return;
431
711
  } catch {
432
712
  if (attempt < MAX_RETRIES) await sleep(RETRY_DELAY_MS);
433
713
  }
434
714
  }
435
715
  try {
436
- fs3.unlinkSync(tmpPath);
716
+ fs4.unlinkSync(tmpPath);
437
717
  } catch {
438
718
  }
439
719
  throw new GwitError("Failed to write worktree registry after multiple retries.");
440
720
  }
441
721
  function readRegistry() {
442
722
  const registryPath = getRegistryPath();
443
- if (!fs3.existsSync(registryPath)) return {};
723
+ if (!fs4.existsSync(registryPath)) return {};
444
724
  try {
445
- return JSON.parse(fs3.readFileSync(registryPath, "utf-8"));
725
+ return JSON.parse(fs4.readFileSync(registryPath, "utf-8"));
446
726
  } catch {
447
727
  const backupPath = `${registryPath}.bak`;
448
728
  try {
449
- fs3.copyFileSync(registryPath, backupPath);
729
+ fs4.copyFileSync(registryPath, backupPath);
450
730
  } catch {
451
731
  }
452
732
  console.warn(`\u26A0 Corrupted registry backed up to ${backupPath}. Starting fresh.`);
@@ -487,10 +767,10 @@ function listWorktreeEntries(mainPath) {
487
767
  // src/core/ports.ts
488
768
  var MAX_PORT_SCAN = 100;
489
769
  function _isPortFree(port) {
490
- return new Promise((resolve2) => {
770
+ return new Promise((resolve4) => {
491
771
  const server = net.createServer();
492
- server.once("error", () => server.close(() => resolve2(false)));
493
- server.once("listening", () => server.close(() => resolve2(true)));
772
+ server.once("error", () => server.close(() => resolve4(false)));
773
+ server.once("listening", () => server.close(() => resolve4(true)));
494
774
  server.listen(port, "127.0.0.1");
495
775
  });
496
776
  }
@@ -526,8 +806,8 @@ function buildEnvironment(branch, slug, port, worktreePath, mainPath, index) {
526
806
  }
527
807
 
528
808
  // src/core/hooks.ts
529
- var fs4 = __toESM(require("fs"));
530
- var path4 = __toESM(require("path"));
809
+ var fs5 = __toESM(require("fs"));
810
+ var path5 = __toESM(require("path"));
531
811
  var import_child_process2 = require("child_process");
532
812
  var GWITCOMMAND_FILE = ".gwitcommand";
533
813
  var GWITCLEANUP_FILE = ".gwitcleanup";
@@ -535,9 +815,9 @@ function _parseHookLines(content) {
535
815
  return content.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
536
816
  }
537
817
  function readHookFile(mainPath, filename) {
538
- const filePath = path4.join(mainPath, filename);
539
- if (!fs4.existsSync(filePath)) return [];
540
- 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"));
541
821
  }
542
822
  function executeCommands(commands, worktreePath, env, stopOnError, label) {
543
823
  const mergedEnv = { ...process.env, ...env };
@@ -640,6 +920,7 @@ async function createCommand(branch, options) {
640
920
  ui.success(`Worktree ready at ${ui.bold(worktreePath)}`);
641
921
  const copied = copyIncludedFiles(mainPath, worktreePath);
642
922
  if (copied.length > 0) {
923
+ createSnapshot(slug, branch, mainPath, copied);
643
924
  ui.success(`Copied ${copied.length} file${copied.length === 1 ? "" : "s"} from .gwitinclude`);
644
925
  copied.forEach((f) => ui.dim(` ${f}`));
645
926
  }
@@ -703,7 +984,7 @@ function listCommand() {
703
984
  }
704
985
 
705
986
  // src/commands/remove.ts
706
- var fs5 = __toESM(require("fs"));
987
+ var fs6 = __toESM(require("fs"));
707
988
  function removeUnregisteredWorktree(branch, force) {
708
989
  const worktrees = listWorktrees();
709
990
  const match = worktrees.find((w) => w.branch === branch);
@@ -733,7 +1014,7 @@ async function removeCommand(branch, options) {
733
1014
  return;
734
1015
  }
735
1016
  const worktreePath = entry.path;
736
- if (!force && fs5.existsSync(worktreePath) && hasUncommittedChanges(worktreePath)) {
1017
+ if (!force && fs6.existsSync(worktreePath) && hasUncommittedChanges(worktreePath)) {
737
1018
  throw new GwitError(
738
1019
  `Worktree '${branch}' has uncommitted changes.`,
739
1020
  `Use 'gwit remove ${branch} --force' to remove anyway, or commit first.`
@@ -749,13 +1030,14 @@ async function removeCommand(branch, options) {
749
1030
  );
750
1031
  runCleanupHooks(mainPath, worktreePath, env);
751
1032
  ui.step(`Removing worktree at ${worktreePath}\u2026`);
752
- if (fs5.existsSync(worktreePath)) {
1033
+ if (fs6.existsSync(worktreePath)) {
753
1034
  removeWorktree(worktreePath, force);
754
1035
  } else {
755
1036
  runArgs("git", ["worktree", "prune"]);
756
1037
  ui.dim(" (directory not found \u2014 pruned git bookkeeping)");
757
1038
  }
758
1039
  await removeWorktreeEntry(mainPath, branch);
1040
+ deleteSnapshot(entry.slug);
759
1041
  ui.success(`Removed worktree for '${branch}' (port ${entry.port} freed)`);
760
1042
  }
761
1043
 
@@ -816,8 +1098,8 @@ function configSetCommand(key, value) {
816
1098
  }
817
1099
 
818
1100
  // src/commands/init.ts
819
- var fs6 = __toESM(require("fs"));
820
- var path5 = __toESM(require("path"));
1101
+ var fs7 = __toESM(require("fs"));
1102
+ var path6 = __toESM(require("path"));
821
1103
  var import_inquirer2 = __toESM(require("inquirer"));
822
1104
  var GWITINCLUDE_FILE2 = ".gwitinclude";
823
1105
  var GWITCOMMAND_FILE2 = ".gwitcommand";
@@ -846,23 +1128,23 @@ var CLEANUP_HEADER = [
846
1128
  "# All $GWIT_* variables are available."
847
1129
  ].join("\n");
848
1130
  function _detectAppName(mainPath) {
849
- const pkgPath = path5.join(mainPath, "package.json");
850
- if (fs6.existsSync(pkgPath)) {
1131
+ const pkgPath = path6.join(mainPath, "package.json");
1132
+ if (fs7.existsSync(pkgPath)) {
851
1133
  try {
852
- const pkg2 = JSON.parse(fs6.readFileSync(pkgPath, "utf-8"));
1134
+ const pkg2 = JSON.parse(fs7.readFileSync(pkgPath, "utf-8"));
853
1135
  if (pkg2.name && pkg2.name.trim().length > 0) {
854
1136
  return pkg2.name.replace(/^@[^/]+\//, "").trim();
855
1137
  }
856
1138
  } catch {
857
1139
  }
858
1140
  }
859
- return path5.basename(mainPath);
1141
+ return path6.basename(mainPath);
860
1142
  }
861
1143
  function _detectPackageManager(mainPath) {
862
- if (fs6.existsSync(path5.join(mainPath, "bun.lockb"))) return "bun";
863
- if (fs6.existsSync(path5.join(mainPath, "pnpm-lock.yaml"))) return "pnpm";
864
- if (fs6.existsSync(path5.join(mainPath, "yarn.lock"))) return "yarn";
865
- 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";
866
1148
  return null;
867
1149
  }
868
1150
  function scanGitignored(mainPath) {
@@ -875,8 +1157,8 @@ function scanGitignored(mainPath) {
875
1157
  return result.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
876
1158
  }
877
1159
  function writeHookFile(filePath, content, force) {
878
- if (fs6.existsSync(filePath) && !force) return "skipped";
879
- fs6.writeFileSync(filePath, content, "utf-8");
1160
+ if (fs7.existsSync(filePath) && !force) return "skipped";
1161
+ fs7.writeFileSync(filePath, content, "utf-8");
880
1162
  return "written";
881
1163
  }
882
1164
  async function collectCommands(firstMessage, continueMessage) {
@@ -1081,7 +1363,7 @@ async function initCommand(options) {
1081
1363
  const detectedAppName = _detectAppName(mainPath);
1082
1364
  const detectedPm = _detectPackageManager(mainPath);
1083
1365
  console.log();
1084
- ui.info(`Setting up gwit for ${ui.bold(path5.basename(mainPath))}`);
1366
+ ui.info(`Setting up gwit for ${ui.bold(path6.basename(mainPath))}`);
1085
1367
  if (!options.force) {
1086
1368
  ui.dim(" Existing files are skipped \u2014 use --force to overwrite.");
1087
1369
  }
@@ -1098,7 +1380,7 @@ async function initCommand(options) {
1098
1380
  { name: GWITCLEANUP_FILE2, content: _buildCleanupContent(sel.appName, sel) }
1099
1381
  ];
1100
1382
  for (const { name, content } of files) {
1101
- const result = writeHookFile(path5.join(mainPath, name), content, options.force);
1383
+ const result = writeHookFile(path6.join(mainPath, name), content, options.force);
1102
1384
  if (result === "written") {
1103
1385
  ui.success(` ${name}`);
1104
1386
  } else {
@@ -1134,7 +1416,117 @@ function openCommand(branch, options) {
1134
1416
  }
1135
1417
 
1136
1418
  // src/commands/sync.ts
1137
- 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
1138
1530
  function detectCurrentBranch(mainPath) {
1139
1531
  const currentRoot = getRepoRoot();
1140
1532
  if (currentRoot === mainPath) {
@@ -1156,7 +1548,45 @@ function detectCurrentBranch(mainPath) {
1156
1548
  }
1157
1549
  return current.branch;
1158
1550
  }
1159
- 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 = {}) {
1160
1590
  if (!isGitRepo()) {
1161
1591
  throw new GwitError(
1162
1592
  "Not a git repository.",
@@ -1172,26 +1602,403 @@ function syncCommand(branch) {
1172
1602
  `Run 'gwit list' to see active worktrees.`
1173
1603
  );
1174
1604
  }
1175
- if (!fs7.existsSync(entry.path)) {
1605
+ if (!fs9.existsSync(entry.path)) {
1176
1606
  throw new GwitError(
1177
1607
  `Worktree path no longer exists: ${entry.path}`,
1178
1608
  `Run 'gwit remove ${targetBranch}' to clean up the stale registry entry.`
1179
1609
  );
1180
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
+ }
1181
1629
  ui.step(`Syncing .gwitinclude into ${entry.path}\u2026`);
1182
1630
  const copied = copyIncludedFiles(mainPath, entry.path);
1183
1631
  if (copied.length > 0) {
1184
1632
  ui.success(`Synced ${copied.length} file${copied.length === 1 ? "" : "s"}`);
1185
- copied.forEach((f) => ui.dim(` ${f}`));
1633
+ logFileList(copied);
1186
1634
  } else {
1187
1635
  ui.info("Nothing to sync \u2014 no .gwitinclude entries were copied.");
1188
1636
  }
1189
1637
  }
1190
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
+
1191
2000
  // src/index.ts
1192
- var pkg = JSON.parse(
1193
- fs8.readFileSync(path6.join(__dirname, "..", "package.json"), "utf-8")
1194
- );
2001
+ var pkg = JSON.parse(fs13.readFileSync(path8.join(__dirname, "..", "package.json"), "utf-8"));
1195
2002
  var program = new import_commander.Command();
1196
2003
  program.name("gwit").description("Fully isolated git worktrees for parallel development").version(pkg.version);
1197
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) => {
@@ -1223,8 +2030,20 @@ configCmd.action(() => {
1223
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) => {
1224
2031
  openCommand(branch, options);
1225
2032
  });
1226
- program.command("sync [branch]").description("Re-copy .gwitinclude files into an existing worktree").action((branch) => {
1227
- 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);
1228
2047
  });
1229
2048
  program.parseAsync(process.argv).catch((err) => {
1230
2049
  if (err instanceof GwitError) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivanshshrivas/gwit",
3
- "version": "0.1.3",
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
  }