yggtree 1.1.0 β†’ 1.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.
package/README.md CHANGED
@@ -95,6 +95,9 @@ Create, manage, and navigate Git worktrees as a primary workflow, not an afterth
95
95
  🧠 **Parallel development by default**
96
96
  Work on multiple branches at the same time, each in its own isolated environment.
97
97
 
98
+ πŸ§ͺ **Sandbox worktrees for experimentation**
99
+ Prototyping something risky? Create a sandbox with a random name, try different strategies, and apply the winner back to your origin branch.
100
+
98
101
  πŸ€– **AI-friendly isolation**
99
102
  One worktree per agent, per experiment, per idea. No shared state, no collisions.
100
103
 
@@ -153,6 +156,21 @@ All in parallel. All reviewable. All isolated.
153
156
 
154
157
  ---
155
158
 
159
+ ## πŸ§ͺ Sandbox Worktrees
160
+
161
+ Sometimes you don't want to "commit to a branch" yet. You just want to try something outβ€”or perhaps try three different ways of solving the same problem.
162
+
163
+ **Sandboxes** are temporary, local-only worktrees designed for this:
164
+
165
+ 1. **Create**: `yggtree wt create-sandbox` (creates `branch_qes2`).
166
+ 2. **Experiment**: Change files, run tests, try that risky refactor.
167
+ 3. **Apply**: `yggtree wt apply` to "push" those file changes back to your origin directory.
168
+ 4. **Unapply**: Don't like it? `yggtree wt unapply` restores your origin to exactly how it was before.
169
+
170
+ Sandboxes are **not pushed to remote** and their names are randomly generated because they are meant to be temporary playgrounds.
171
+
172
+ ---
173
+
156
174
  ## ⚑ Bootstrapping & Configuration
157
175
 
158
176
  Yggdrasil automatically prepares each worktree.
@@ -210,6 +228,35 @@ yggtree wt create feat/new-ui --base main --exec "cursor ."
210
228
 
211
229
  ---
212
230
 
231
+ ### `yggtree wt create-sandbox`
232
+
233
+ Create a temporary sandbox from your current local branch.
234
+
235
+ Options:
236
+
237
+ * `--carry / --no-carry`: Bring uncommitted changes (staged/unstaged/untracked) with you.
238
+ * `--no-bootstrap`
239
+ * `--enter / --no-enter`
240
+ * `--exec "<command>"`
241
+
242
+ ---
243
+
244
+ ### `yggtree wt apply`
245
+
246
+ Apply changes from the current sandbox back to the origin repository.
247
+ * **Backs up** origin files before overwriting.
248
+ * **Offers to delete** the sandbox after applying.
249
+
250
+ ---
251
+
252
+ ### `yggtree wt unapply`
253
+
254
+ Undo a previous `apply` operation.
255
+ * Restores origin files from the sandbox's backup.
256
+ * *Note: Only works if the sandbox worktree still exists.*
257
+
258
+ ---
259
+
213
260
  ### `yggtree wt create-multi`
214
261
 
215
262
  Create multiple worktrees at once.
@@ -428,6 +475,27 @@ Useful when you want to manually navigate or copy the path into scripts.
428
475
 
429
476
  ---
430
477
 
478
+ <details>
479
+ <summary>Try a risky refactor in a Sandbox</summary>
480
+
481
+ **Command:**
482
+
483
+ ```bash
484
+ yggtree wt create-sandbox --carry
485
+ ```
486
+
487
+ **Scenario:**
488
+
489
+ 1. You have 5 files changed in your main repo but aren't sure about the direction.
490
+ 2. Run `create-sandbox --carry` to move those changes into an isolated `current-branch_a1b2` folder.
491
+ 3. Experiment freely.
492
+ 4. If it works: `yggtree wt apply`.
493
+ 5. If it fails: Just delete the sandbox or `unapply`.
494
+
495
+ </details>
496
+
497
+ ---
498
+
431
499
  ## 🌍 Philosophy
432
500
 
433
501
  Branches are ideas.
@@ -0,0 +1,128 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import path from 'path';
4
+ import fs from 'fs-extra';
5
+ import { execa } from 'execa';
6
+ import { log, ui, createSpinner } from '../../lib/ui.js';
7
+ import { findSandboxRoot, readSandboxMeta, writeSandboxMeta } from '../../lib/sandbox.js';
8
+ export async function applyCommand() {
9
+ try {
10
+ const cwd = process.cwd();
11
+ // 1. Find sandbox root
12
+ const sandboxRoot = await findSandboxRoot(cwd);
13
+ if (!sandboxRoot) {
14
+ log.error('Not inside a sandbox worktree.');
15
+ log.info(`Run ${chalk.cyan('yggtree wt create-sandbox')} to create one first.`);
16
+ return;
17
+ }
18
+ // 2. Read sandbox metadata
19
+ const meta = await readSandboxMeta(sandboxRoot);
20
+ if (!meta) {
21
+ log.error('Could not read sandbox metadata.');
22
+ return;
23
+ }
24
+ log.info(`Sandbox: ${chalk.cyan(path.basename(sandboxRoot))}`);
25
+ log.info(`Origin: ${chalk.dim(meta.originPath)} (${chalk.yellow(meta.originBranch)})`);
26
+ // 3. Get list of changed files (including untracked)
27
+ const spinner = createSpinner('Detecting changes...').start();
28
+ let changedFiles = [];
29
+ try {
30
+ const { stdout: diffFiles } = await execa('git', ['diff', '--name-only', 'HEAD'], { cwd: sandboxRoot });
31
+ const { stdout: stagedFiles } = await execa('git', ['diff', '--name-only', '--cached'], { cwd: sandboxRoot });
32
+ const { stdout: untrackedFiles } = await execa('git', ['ls-files', '--others', '--exclude-standard'], { cwd: sandboxRoot });
33
+ const allChanges = new Set([
34
+ ...diffFiles.split('\n').filter(Boolean),
35
+ ...stagedFiles.split('\n').filter(Boolean),
36
+ ...untrackedFiles.split('\n').filter(Boolean)
37
+ ]);
38
+ changedFiles = [...allChanges];
39
+ }
40
+ catch (e) {
41
+ spinner.fail('Failed to detect changes.');
42
+ log.error(e.message);
43
+ return;
44
+ }
45
+ if (changedFiles.length === 0) {
46
+ spinner.info('No changes detected.');
47
+ return;
48
+ }
49
+ spinner.succeed(`Found ${changedFiles.length} changed file(s).`);
50
+ // Show changes
51
+ console.log(chalk.dim('\n Changed files:'));
52
+ for (const file of changedFiles) {
53
+ console.log(chalk.yellow(` ${file}`));
54
+ }
55
+ console.log('');
56
+ // 4. Confirm apply
57
+ const { confirm } = await inquirer.prompt([
58
+ {
59
+ type: 'confirm',
60
+ name: 'confirm',
61
+ message: `Apply ${changedFiles.length} file(s) to origin?`,
62
+ default: true,
63
+ }
64
+ ]);
65
+ if (!confirm) {
66
+ log.info('Cancelled.');
67
+ return;
68
+ }
69
+ // 5. Backup original files and copy
70
+ const applySpinner = createSpinner('Applying changes...').start();
71
+ const backups = [];
72
+ for (const relativePath of changedFiles) {
73
+ const originFile = path.join(meta.originPath, relativePath);
74
+ const sandboxFile = path.join(sandboxRoot, relativePath);
75
+ // Skip if sandbox file doesn't exist or is a directory
76
+ if (!await fs.pathExists(sandboxFile)) {
77
+ continue;
78
+ }
79
+ const sandboxStat = await fs.stat(sandboxFile);
80
+ if (sandboxStat.isDirectory()) {
81
+ continue; // Skip directories (e.g., submodules)
82
+ }
83
+ // Backup original content (or null if didn't exist)
84
+ let originalContent = null;
85
+ if (await fs.pathExists(originFile)) {
86
+ const originStat = await fs.stat(originFile);
87
+ if (originStat.isFile()) {
88
+ originalContent = await fs.readFile(originFile, 'utf-8');
89
+ }
90
+ }
91
+ backups.push({ relativePath, originalContent });
92
+ // Copy from sandbox to origin
93
+ await fs.ensureDir(path.dirname(originFile));
94
+ await fs.copy(sandboxFile, originFile);
95
+ }
96
+ // 6. Update metadata with backups
97
+ meta.appliedFiles = backups;
98
+ await writeSandboxMeta(sandboxRoot, meta);
99
+ applySpinner.succeed(`Applied ${changedFiles.length} file(s) to origin.`);
100
+ log.info(`Use ${chalk.cyan('yggtree wt unapply')} to undo these changes.`);
101
+ // 7. Offer to delete sandbox
102
+ log.warning(`If you delete this sandbox, you won't be able to run ${chalk.cyan('unapply')} later.`);
103
+ const { shouldDelete } = await inquirer.prompt([
104
+ {
105
+ type: 'confirm',
106
+ name: 'shouldDelete',
107
+ message: 'Delete this sandbox worktree?',
108
+ default: false,
109
+ }
110
+ ]);
111
+ if (shouldDelete) {
112
+ const deleteSpinner = createSpinner('Deleting sandbox...').start();
113
+ try {
114
+ await execa('git', ['worktree', 'remove', sandboxRoot, '--force'], { cwd: meta.originPath });
115
+ deleteSpinner.succeed('Sandbox deleted.');
116
+ log.info(`Returning to origin: ${ui.path(meta.originPath)}`);
117
+ }
118
+ catch (e) {
119
+ deleteSpinner.fail('Failed to delete sandbox.');
120
+ log.warning('You can delete it manually with: yggtree wt delete');
121
+ }
122
+ }
123
+ }
124
+ catch (error) {
125
+ log.actionableError(error.message, 'yggtree wt apply');
126
+ process.exit(1);
127
+ }
128
+ }
@@ -1,7 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import inquirer from 'inquirer';
3
3
  import path from 'path';
4
- import { getRepoRoot, getRepoName, verifyRef, fetchAll, getCurrentBranch } from '../../lib/git.js';
4
+ import { getRepoRoot, getRepoName, verifyRef, fetchAll, getCurrentBranch, ensureCorrectUpstream } from '../../lib/git.js';
5
5
  import { runBootstrap } from '../../lib/config.js';
6
6
  import { WORKTREES_ROOT } from '../../lib/paths.js';
7
7
  import { log, ui, createSpinner } from '../../lib/ui.js';
@@ -110,7 +110,6 @@ export async function createCommandNew(options) {
110
110
  else {
111
111
  await execa('git', ['worktree', 'add', '-b', branchName, wtPath, baseRef]);
112
112
  }
113
- spinner.succeed('Worktree created.');
114
113
  }
115
114
  catch (e) {
116
115
  spinner.fail('Failed to create worktree.');
@@ -125,6 +124,21 @@ export async function createCommandNew(options) {
125
124
  ]);
126
125
  return;
127
126
  }
127
+ try {
128
+ // Strong Safety Mode: Ensure upstream is origin/<branchName> and publish
129
+ spinner.text = 'Safely publishing branch...';
130
+ await ensureCorrectUpstream(wtPath, branchName);
131
+ spinner.succeed('Worktree created and branch published.');
132
+ }
133
+ catch (e) {
134
+ spinner.fail('Worktree created, but branch publication failed.');
135
+ log.actionableError(e.message, 'git push -u origin HEAD', wtPath, [
136
+ `cd ${wtPath}`,
137
+ 'Attempt to push manually: git push -u origin HEAD',
138
+ 'Check if the remote branch already exists or if you have push permissions'
139
+ ]);
140
+ // We don't return here because the worktree IS created, we just failed to publish
141
+ }
128
142
  if (shouldBootstrap) {
129
143
  await runBootstrap(wtPath, repoRoot);
130
144
  }
@@ -1,7 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import inquirer from 'inquirer';
3
3
  import path from 'path';
4
- import { getRepoRoot, getRepoName, verifyRef, fetchAll, getCurrentBranch } from '../../lib/git.js';
4
+ import { getRepoRoot, getRepoName, verifyRef, fetchAll, getCurrentBranch, ensureCorrectUpstream } from '../../lib/git.js';
5
5
  import { runBootstrap } from '../../lib/config.js';
6
6
  import { WORKTREES_ROOT } from '../../lib/paths.js';
7
7
  import { log, ui, createSpinner } from '../../lib/ui.js';
@@ -84,12 +84,6 @@ export async function createCommandMulti(options) {
84
84
  else {
85
85
  await execa('git', ['worktree', 'add', '-b', branchName, wtPath, baseRef]);
86
86
  }
87
- wtSpinner.succeed(`Worktree for ${chalk.cyan(branchName)} created.`);
88
- createdWorktrees.push(wtPath);
89
- // 4. Bootstrap
90
- if (shouldBootstrap) {
91
- await runBootstrap(wtPath, repoRoot);
92
- }
93
87
  }
94
88
  catch (error) {
95
89
  wtSpinner.fail(`Failed to create worktree for ${branchName}.`);
@@ -102,6 +96,27 @@ export async function createCommandMulti(options) {
102
96
  'Try pruning stale worktrees: yggtree wt prune',
103
97
  `Run manually: ${cmd}`
104
98
  ]);
99
+ continue;
100
+ }
101
+ try {
102
+ // Strong Safety Mode: Ensure upstream is origin/<branchName> and publish
103
+ wtSpinner.text = `Safely publishing branch ${branchName}...`;
104
+ await ensureCorrectUpstream(wtPath, branchName);
105
+ wtSpinner.succeed(`Worktree for ${chalk.cyan(branchName)} created and published.`);
106
+ createdWorktrees.push(wtPath);
107
+ }
108
+ catch (error) {
109
+ wtSpinner.fail(`Worktree for ${branchName} created, but publication failed.`);
110
+ log.actionableError(error.message, 'git push -u origin HEAD', wtPath, [
111
+ `cd ${wtPath}`,
112
+ 'Attempt to push manually: git push -u origin HEAD',
113
+ 'Check if the remote branch already exists or if you have push permissions'
114
+ ]);
115
+ createdWorktrees.push(wtPath); // Still added to list since wt exists
116
+ }
117
+ // 4. Bootstrap
118
+ if (shouldBootstrap) {
119
+ await runBootstrap(wtPath, repoRoot);
105
120
  }
106
121
  }
107
122
  // 5. Final Output
@@ -0,0 +1,186 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import path from 'path';
4
+ import { getRepoRoot, getRepoName, getCurrentBranch } from '../../lib/git.js';
5
+ import { runBootstrap } from '../../lib/config.js';
6
+ import { WORKTREES_ROOT } from '../../lib/paths.js';
7
+ import { log, ui, createSpinner } from '../../lib/ui.js';
8
+ import { generateSandboxName, writeSandboxMeta } from '../../lib/sandbox.js';
9
+ import { execa } from 'execa';
10
+ import { spawn } from 'child_process';
11
+ import fs from 'fs-extra';
12
+ export async function createSandboxCommand(options = {}) {
13
+ try {
14
+ const repoRoot = await getRepoRoot();
15
+ log.info(`Repo: ${chalk.dim(repoRoot)}`);
16
+ // 1. Auto-detect current branch (no prompt!)
17
+ const currentBranch = await getCurrentBranch();
18
+ if (!currentBranch) {
19
+ log.error('Could not detect current branch. Are you in a git repository?');
20
+ return;
21
+ }
22
+ log.info(`Current branch: ${chalk.cyan(currentBranch)}`);
23
+ // 2. Generate random sandbox name
24
+ const sandboxName = generateSandboxName(currentBranch);
25
+ log.info(`Sandbox name: ${chalk.yellow(sandboxName)}`);
26
+ // 3. Gather remaining inputs
27
+ const answers = await inquirer.prompt([
28
+ {
29
+ type: 'confirm',
30
+ name: 'carry',
31
+ message: 'Carry uncommitted changes to sandbox?',
32
+ default: true,
33
+ when: options.carry === undefined,
34
+ },
35
+ {
36
+ type: 'confirm',
37
+ name: 'bootstrap',
38
+ message: 'Run bootstrap? (npm install + submodules)',
39
+ default: true,
40
+ when: options.bootstrap === undefined,
41
+ },
42
+ {
43
+ type: 'confirm',
44
+ name: 'shouldEnter',
45
+ message: 'Do you want to enter the sandbox now?',
46
+ default: true,
47
+ when: options.enter === undefined,
48
+ },
49
+ {
50
+ type: 'input',
51
+ name: 'exec',
52
+ message: 'Command to run after creation (optional):',
53
+ default: options.exec,
54
+ when: options.exec === undefined,
55
+ }
56
+ ]);
57
+ const shouldCarry = options.carry !== undefined ? options.carry : answers.carry;
58
+ const shouldEnter = options.enter !== undefined ? options.enter : answers.shouldEnter;
59
+ const shouldBootstrap = options.bootstrap !== undefined ? options.bootstrap : answers.bootstrap;
60
+ const execCommandStr = options.exec || answers.exec;
61
+ // 4. Detect uncommitted changes before creating worktree
62
+ let changedFiles = [];
63
+ let submodulePaths = [];
64
+ if (shouldCarry) {
65
+ try {
66
+ // Get list of submodules to exclude from carry
67
+ try {
68
+ const { stdout: submodules } = await execa('git', ['config', '--file', '.gitmodules', '--get-regexp', 'path'], { cwd: repoRoot });
69
+ submodulePaths = submodules.split('\n')
70
+ .filter(Boolean)
71
+ .map(line => line.split(' ')[1])
72
+ .filter(Boolean);
73
+ }
74
+ catch {
75
+ // No submodules or .gitmodules doesn't exist
76
+ }
77
+ const { stdout: unstaged } = await execa('git', ['diff', '--name-only'], { cwd: repoRoot });
78
+ const { stdout: staged } = await execa('git', ['diff', '--name-only', '--cached'], { cwd: repoRoot });
79
+ const { stdout: untracked } = await execa('git', ['ls-files', '--others', '--exclude-standard'], { cwd: repoRoot });
80
+ const allChanges = new Set([
81
+ ...unstaged.split('\n').filter(Boolean),
82
+ ...staged.split('\n').filter(Boolean),
83
+ ...untracked.split('\n').filter(Boolean)
84
+ ]);
85
+ // Filter out files inside submodule directories
86
+ changedFiles = [...allChanges].filter(file => {
87
+ return !submodulePaths.some(subPath => file === subPath || file.startsWith(subPath + '/'));
88
+ });
89
+ }
90
+ catch {
91
+ // Ignore errors, proceed without carrying
92
+ }
93
+ }
94
+ // 5. Create worktree
95
+ const repoName = await getRepoName();
96
+ const wtPath = path.join(WORKTREES_ROOT, repoName, sandboxName);
97
+ const spinner = createSpinner('Creating sandbox worktree...').start();
98
+ try {
99
+ await fs.ensureDir(path.dirname(wtPath));
100
+ // Create new branch from current HEAD (local branch)
101
+ await execa('git', ['worktree', 'add', '-b', sandboxName, wtPath, 'HEAD']);
102
+ }
103
+ catch (e) {
104
+ spinner.fail('Failed to create sandbox worktree.');
105
+ log.actionableError(e.message, `git worktree add -b ${sandboxName} ${wtPath} HEAD`, wtPath, [
106
+ 'Check if the folder already exists: ls ' + wtPath,
107
+ 'Check if the branch is already used: git worktree list',
108
+ 'Try pruning stale worktrees: yggtree wt prune',
109
+ ]);
110
+ return;
111
+ }
112
+ // 6. Carry over uncommitted changes (excluding submodules)
113
+ if (shouldCarry && changedFiles.length > 0) {
114
+ spinner.text = `Carrying ${changedFiles.length} uncommitted file(s)...`;
115
+ for (const file of changedFiles) {
116
+ const srcFile = path.join(repoRoot, file);
117
+ const destFile = path.join(wtPath, file);
118
+ try {
119
+ if (await fs.pathExists(srcFile)) {
120
+ await fs.ensureDir(path.dirname(destFile));
121
+ await fs.copy(srcFile, destFile);
122
+ }
123
+ }
124
+ catch {
125
+ // Skip files that can't be copied
126
+ }
127
+ }
128
+ log.info(`Carried ${changedFiles.length} uncommitted file(s) to sandbox.`);
129
+ if (submodulePaths.length > 0) {
130
+ log.dim(`Submodules excluded (will be initialized by bootstrap): ${submodulePaths.join(', ')}`);
131
+ }
132
+ }
133
+ // 7. Write sandbox metadata (NO remote push for sandbox!)
134
+ await writeSandboxMeta(wtPath, {
135
+ originPath: repoRoot,
136
+ originBranch: currentBranch,
137
+ createdAt: new Date().toISOString(),
138
+ });
139
+ spinner.succeed(`Sandbox created: ${chalk.cyan(sandboxName)}`);
140
+ log.dim('This is a local sandbox - no remote branch will be pushed.');
141
+ // 8. Bootstrap
142
+ if (shouldBootstrap) {
143
+ await runBootstrap(wtPath, repoRoot);
144
+ }
145
+ // 9. Exec command
146
+ if (execCommandStr && execCommandStr.trim()) {
147
+ log.info(`Executing: ${execCommandStr} in ${ui.path(wtPath)}`);
148
+ try {
149
+ await execa(execCommandStr, {
150
+ cwd: wtPath,
151
+ stdio: 'inherit',
152
+ shell: true
153
+ });
154
+ }
155
+ catch (error) {
156
+ log.actionableError(error.message, execCommandStr, wtPath, [
157
+ `cd ${wtPath} && ${execCommandStr}`,
158
+ 'Check your command syntax and environment variables'
159
+ ]);
160
+ }
161
+ }
162
+ // 10. Final output
163
+ log.success('Sandbox ready!');
164
+ log.info(`Use ${chalk.cyan('yggtree wt apply')} to apply changes to origin.`);
165
+ log.info(`Use ${chalk.cyan('yggtree wt unapply')} to undo applied changes.`);
166
+ if (shouldEnter) {
167
+ log.info(`Spawning sub-shell in ${ui.path(wtPath)}...`);
168
+ log.dim('Type "exit" to return to the main terminal.');
169
+ const shell = process.env.SHELL || 'zsh';
170
+ const child = spawn(shell, [], {
171
+ cwd: wtPath,
172
+ stdio: 'inherit',
173
+ });
174
+ child.on('close', () => {
175
+ log.info('Exited sub-shell.');
176
+ });
177
+ }
178
+ else {
179
+ log.header(`cd "${wtPath}"`);
180
+ }
181
+ }
182
+ catch (error) {
183
+ log.actionableError(error.message, 'yggtree wt create-sandbox');
184
+ process.exit(1);
185
+ }
186
+ }
@@ -0,0 +1,79 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import path from 'path';
4
+ import fs from 'fs-extra';
5
+ import { log, createSpinner } from '../../lib/ui.js';
6
+ import { findSandboxRoot, readSandboxMeta, writeSandboxMeta } from '../../lib/sandbox.js';
7
+ export async function unapplyCommand() {
8
+ try {
9
+ const cwd = process.cwd();
10
+ // 1. Find sandbox root
11
+ const sandboxRoot = await findSandboxRoot(cwd);
12
+ if (!sandboxRoot) {
13
+ log.error('Not inside a sandbox worktree.');
14
+ log.info(`This command only works inside a sandbox created with ${chalk.cyan('yggtree wt create-sandbox')}.`);
15
+ return;
16
+ }
17
+ // 2. Read sandbox metadata
18
+ const meta = await readSandboxMeta(sandboxRoot);
19
+ if (!meta) {
20
+ log.error('Could not read sandbox metadata.');
21
+ return;
22
+ }
23
+ // 3. Check if there are applied changes to unapply
24
+ if (!meta.appliedFiles || meta.appliedFiles.length === 0) {
25
+ log.info('No applied changes to unapply.');
26
+ log.dim('Use "yggtree wt apply" first to apply changes to origin.');
27
+ return;
28
+ }
29
+ log.info(`Sandbox: ${chalk.cyan(path.basename(sandboxRoot))}`);
30
+ log.info(`Origin: ${chalk.dim(meta.originPath)} (${chalk.yellow(meta.originBranch)})`);
31
+ log.info(`Applied files: ${chalk.yellow(meta.appliedFiles.length)}`);
32
+ // Show files that will be reverted
33
+ console.log(chalk.dim('\n Files to revert in origin:'));
34
+ for (const backup of meta.appliedFiles) {
35
+ const action = backup.originalContent === null ? '(delete)' : '(restore)';
36
+ console.log(chalk.yellow(` ${backup.relativePath} ${chalk.dim(action)}`));
37
+ }
38
+ console.log('');
39
+ // 4. Confirm unapply
40
+ const { confirm } = await inquirer.prompt([
41
+ {
42
+ type: 'confirm',
43
+ name: 'confirm',
44
+ message: `Revert ${meta.appliedFiles.length} file(s) in origin?`,
45
+ default: true,
46
+ }
47
+ ]);
48
+ if (!confirm) {
49
+ log.info('Cancelled.');
50
+ return;
51
+ }
52
+ // 5. Restore original files
53
+ const spinner = createSpinner('Reverting changes in origin...').start();
54
+ for (const backup of meta.appliedFiles) {
55
+ const originFile = path.join(meta.originPath, backup.relativePath);
56
+ if (backup.originalContent === null) {
57
+ // File was newly created by apply, delete it
58
+ if (await fs.pathExists(originFile)) {
59
+ await fs.remove(originFile);
60
+ }
61
+ }
62
+ else {
63
+ // Restore original content
64
+ await fs.ensureDir(path.dirname(originFile));
65
+ await fs.writeFile(originFile, backup.originalContent, 'utf-8');
66
+ }
67
+ }
68
+ // 6. Clear applied files from metadata
69
+ const revertedCount = meta.appliedFiles.length;
70
+ meta.appliedFiles = [];
71
+ await writeSandboxMeta(sandboxRoot, meta);
72
+ spinner.succeed(`Reverted ${revertedCount} file(s) in origin.`);
73
+ log.info('Origin is back to its state before apply.');
74
+ }
75
+ catch (error) {
76
+ log.actionableError(error.message, 'yggtree wt unapply');
77
+ process.exit(1);
78
+ }
79
+ }
package/dist/index.js CHANGED
@@ -5,12 +5,15 @@ import { listCommand } from './commands/wt/list.js';
5
5
  import { createCommand } from './commands/wt/create.js';
6
6
  import { createCommandNew } from './commands/wt/create-branch.js';
7
7
  import { createCommandMulti } from './commands/wt/create-multi.js';
8
+ import { createSandboxCommand } from './commands/wt/create-sandbox.js';
8
9
  import { deleteCommand } from './commands/wt/delete.js';
9
10
  import { bootstrapCommand } from './commands/wt/bootstrap.js';
10
11
  import { pruneCommand } from './commands/wt/prune.js';
11
12
  import { execCommand } from './commands/wt/exec.js';
12
13
  import { enterCommand } from './commands/wt/enter.js';
13
14
  import { pathCommand } from './commands/wt/path.js';
15
+ import { applyCommand } from './commands/wt/apply.js';
16
+ import { unapplyCommand } from './commands/wt/unapply.js';
14
17
  import { getVersion } from './lib/version.js';
15
18
  const program = new Command();
16
19
  program
@@ -29,15 +32,19 @@ program
29
32
  choices: [
30
33
  { name: '🌿 Create new worktree (Smart Branch)', value: 'create-smart' },
31
34
  { name: '🌳 Create multiple worktrees', value: 'create-multi' },
32
- { name: '🌱 Create new worktree (Manual Slug)', value: 'create-slug' },
35
+ { name: 'πŸ§ͺ Create sandbox worktree', value: 'create-sandbox' },
36
+ // { name: '🌱 Create new worktree (Manual Slug)', value: 'create-slug' },
33
37
  { name: 'πŸ“‹ List worktrees', value: 'list' },
34
- { name: 'πŸ—‘οΈ Delete worktree', value: 'delete' },
38
+ { name: 'πŸͺ“ Delete worktree', value: 'delete' },
35
39
  { name: 'πŸš€ Bootstrap worktree', value: 'bootstrap' },
36
40
  { name: '🧹 Prune stale worktrees', value: 'prune' },
37
41
  { name: '🐚 Exec command', value: 'exec' },
38
42
  { name: 'πŸšͺ Enter worktree', value: 'enter' },
39
43
  { name: 'πŸ“ Get worktree path', value: 'path' },
40
44
  new inquirer.Separator(),
45
+ { name: 'βœ… Apply sandbox changes', value: 'apply' },
46
+ { name: '↩️ Unapply sandbox changes', value: 'unapply' },
47
+ new inquirer.Separator(),
41
48
  { name: 'πŸšͺ Exit', value: 'exit' },
42
49
  ],
43
50
  },
@@ -73,6 +80,15 @@ program
73
80
  case 'path':
74
81
  await pathCommand();
75
82
  break;
83
+ case 'apply':
84
+ await applyCommand();
85
+ break;
86
+ case 'unapply':
87
+ await unapplyCommand();
88
+ break;
89
+ case 'create-sandbox':
90
+ await createSandboxCommand({ bootstrap: true });
91
+ break;
76
92
  case 'exit':
77
93
  log.info('Bye! πŸ‘‹');
78
94
  process.exit(0);
@@ -149,4 +165,21 @@ wt.command('path [worktree]')
149
165
  .action(async (worktree) => {
150
166
  await pathCommand(worktree);
151
167
  });
168
+ wt.command('create-sandbox')
169
+ .description('Create a sandbox worktree from current branch')
170
+ .option('--carry', 'Carry uncommitted changes to sandbox')
171
+ .option('--no-carry', 'Do not carry uncommitted changes')
172
+ .option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
173
+ .option('--enter', 'Enter sub-shell after creation')
174
+ .option('--no-enter', 'Skip entering sub-shell')
175
+ .option('--exec <command>', 'Command to execute after creation')
176
+ .action(async (options) => {
177
+ await createSandboxCommand(options);
178
+ });
179
+ wt.command('apply')
180
+ .description('Apply sandbox changes to origin directory')
181
+ .action(applyCommand);
182
+ wt.command('unapply')
183
+ .description('Undo applied sandbox changes in origin')
184
+ .action(unapplyCommand);
152
185
  program.parse(process.argv);
package/dist/lib/git.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { execa } from 'execa';
2
2
  import fs from 'fs-extra';
3
3
  import path from 'path';
4
+ import { log } from './ui.js';
5
+ import chalk from 'chalk';
4
6
  export async function getRepoRoot() {
5
7
  try {
6
8
  const { stdout } = await execa('git', ['rev-parse', '--show-toplevel']);
@@ -83,3 +85,34 @@ export async function isGitClean(cwd) {
83
85
  return false;
84
86
  }
85
87
  }
88
+ /**
89
+ * Ensures the branch in the given worktree path tracks origin/<branchName>.
90
+ * If it tracks a different base branch (e.g. origin/main), it unsets it and publishes to origin.
91
+ * Fails if origin/<branchName> already exists on remote and is not already the upstream.
92
+ */
93
+ export async function ensureCorrectUpstream(wtPath, branchName) {
94
+ const desiredUpstream = `origin/${branchName}`;
95
+ let currentUpstream = '';
96
+ try {
97
+ const { stdout } = await execa('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], { cwd: wtPath });
98
+ currentUpstream = stdout.trim();
99
+ }
100
+ catch {
101
+ // No upstream set
102
+ }
103
+ if (currentUpstream === desiredUpstream) {
104
+ return; // Already correct
105
+ }
106
+ // If it's set to something else, unset it
107
+ if (currentUpstream) {
108
+ log.info(`Incorrect upstream detected: ${currentUpstream}. Unsetting...`);
109
+ await execa('git', ['branch', '--unset-upstream'], { cwd: wtPath });
110
+ }
111
+ // Check if remote branch already exists
112
+ const remoteExists = await verifyRef(desiredUpstream);
113
+ if (remoteExists) {
114
+ throw new Error(`Remote branch '${desiredUpstream}' already exists. Cannot publish safely without more info. Use 'git push -u origin HEAD' manually if you want to link them.`);
115
+ }
116
+ log.info(`Publishing branch ${chalk.cyan(branchName)} to ${chalk.cyan(desiredUpstream)}...`);
117
+ await execa('git', ['push', '-u', 'origin', 'HEAD'], { cwd: wtPath });
118
+ }
@@ -0,0 +1,61 @@
1
+ import crypto from 'crypto';
2
+ import path from 'path';
3
+ import fs from 'fs-extra';
4
+ const SANDBOX_META_FILE = '.sandbox-meta.json';
5
+ /**
6
+ * Generate a sandbox worktree name: <branch>_<4-char-hash>
7
+ */
8
+ export function generateSandboxName(branch) {
9
+ const hash = crypto.randomBytes(2).toString('hex'); // 4 hex chars
10
+ const safeBranch = branch.replace(/[\\/]/g, '-');
11
+ return `${safeBranch}_${hash}`;
12
+ }
13
+ /**
14
+ * Get the path to the sandbox metadata file
15
+ */
16
+ export function getSandboxMetaPath(wtPath) {
17
+ return path.join(wtPath, SANDBOX_META_FILE);
18
+ }
19
+ /**
20
+ * Write sandbox metadata to the worktree
21
+ */
22
+ export async function writeSandboxMeta(wtPath, meta) {
23
+ const metaPath = getSandboxMetaPath(wtPath);
24
+ await fs.writeJSON(metaPath, meta, { spaces: 2 });
25
+ }
26
+ /**
27
+ * Read sandbox metadata from a worktree
28
+ */
29
+ export async function readSandboxMeta(wtPath) {
30
+ const metaPath = getSandboxMetaPath(wtPath);
31
+ if (await fs.pathExists(metaPath)) {
32
+ try {
33
+ return await fs.readJSON(metaPath);
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ return null;
40
+ }
41
+ /**
42
+ * Check if the current directory is inside a sandbox worktree
43
+ */
44
+ export async function isSandboxWorktree(cwd) {
45
+ const meta = await readSandboxMeta(cwd);
46
+ return meta !== null;
47
+ }
48
+ /**
49
+ * Find the sandbox root from any subdirectory
50
+ */
51
+ export async function findSandboxRoot(startPath) {
52
+ let current = startPath;
53
+ const root = path.parse(current).root;
54
+ while (current !== root) {
55
+ if (await fs.pathExists(getSandboxMetaPath(current))) {
56
+ return current;
57
+ }
58
+ current = path.dirname(current);
59
+ }
60
+ return null;
61
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yggtree",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Interactive CLI for managing git worktrees and configs",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",