yggtree 1.2.1 β 1.3.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 +4 -3
- package/dist/commands/wt/apply.js +7 -3
- package/dist/commands/wt/create-branch.js +14 -14
- package/dist/commands/wt/create-multi.js +13 -10
- package/dist/commands/wt/delete.js +8 -5
- package/dist/commands/wt/list.js +12 -12
- package/dist/lib/config.js +17 -4
- package/dist/lib/git.js +63 -13
- package/dist/lib/sandbox.js +5 -3
- package/dist/lib/ui.js +20 -0
- package/package.json +1 -1
- package/dist/commands/wt/leave.js +0 -13
package/README.md
CHANGED
|
@@ -118,8 +118,8 @@ Use the interactive UI or drive everything through commands and flags.
|
|
|
118
118
|
## π§ Parallel Development, Done Right
|
|
119
119
|
|
|
120
120
|
```bash
|
|
121
|
-
yggtree wt create feat/
|
|
122
|
-
yggtree wt create fix/
|
|
121
|
+
yggtree wt create feat/city-selection
|
|
122
|
+
yggtree wt create fix/validation
|
|
123
123
|
yggtree wt create chore/cleanup-api
|
|
124
124
|
```
|
|
125
125
|
|
|
@@ -373,11 +373,12 @@ yggtree wt create feat/login-flow
|
|
|
373
373
|
|
|
374
374
|
**What happens:**
|
|
375
375
|
|
|
376
|
-
* Creates a new branch if it doesnβt exist
|
|
376
|
+
* Creates a new branch if it doesnβt exist (without inheriting base tracking), then publishes it to `origin` when possible
|
|
377
377
|
* Creates a dedicated worktree
|
|
378
378
|
* Runs bootstrap if enabled
|
|
379
379
|
* Drops you into a sub-shell inside the worktree
|
|
380
380
|
|
|
381
|
+
|
|
381
382
|
</details>
|
|
382
383
|
---
|
|
383
384
|
|
|
@@ -4,7 +4,7 @@ import path from 'path';
|
|
|
4
4
|
import fs from 'fs-extra';
|
|
5
5
|
import { execa } from 'execa';
|
|
6
6
|
import { log, ui, createSpinner } from '../../lib/ui.js';
|
|
7
|
-
import { findSandboxRoot, readSandboxMeta, writeSandboxMeta,
|
|
7
|
+
import { findSandboxRoot, readSandboxMeta, writeSandboxMeta, YGGTREE_DIR } from '../../lib/sandbox.js';
|
|
8
8
|
export async function applyCommand() {
|
|
9
9
|
try {
|
|
10
10
|
const cwd = process.cwd();
|
|
@@ -35,8 +35,12 @@ export async function applyCommand() {
|
|
|
35
35
|
...stagedFiles.split('\n').filter(Boolean),
|
|
36
36
|
...untrackedFiles.split('\n').filter(Boolean)
|
|
37
37
|
]);
|
|
38
|
-
// Exclude internal sandbox metadata
|
|
39
|
-
allChanges
|
|
38
|
+
// Exclude internal .yggtree/ directory (config, sandbox metadata, etc.)
|
|
39
|
+
for (const file of allChanges) {
|
|
40
|
+
if (file.startsWith(`${YGGTREE_DIR}/`)) {
|
|
41
|
+
allChanges.delete(file);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
40
44
|
changedFiles = [...allChanges];
|
|
41
45
|
}
|
|
42
46
|
catch (e) {
|
|
@@ -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, ensureCorrectUpstream } from '../../lib/git.js';
|
|
4
|
+
import { getRepoRoot, getRepoName, verifyRef, fetchAll, getCurrentBranch, ensureCorrectUpstream, publishBranch } 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';
|
|
@@ -76,7 +76,7 @@ export async function createCommandNew(options) {
|
|
|
76
76
|
baseRef = `origin/${baseRef}`;
|
|
77
77
|
}
|
|
78
78
|
// Convert branch name to slug (friendly folder name)
|
|
79
|
-
// e.g. feat/
|
|
79
|
+
// e.g. feat/new-button -> feat-new-button
|
|
80
80
|
const slug = branchName.replace(/[\/\\]/g, '-').replace(/\s+/g, '-');
|
|
81
81
|
const repoName = await getRepoName();
|
|
82
82
|
const wtPath = path.join(WORKTREES_ROOT, repoName, slug);
|
|
@@ -98,24 +98,23 @@ export async function createCommandNew(options) {
|
|
|
98
98
|
spinner.text = `Creating worktree at ${ui.path(wtPath)}...`;
|
|
99
99
|
// Check if target branch already exists
|
|
100
100
|
const targetBranchExists = await verifyRef(branchName);
|
|
101
|
-
// If branch doesn't exist, we create it from base
|
|
102
|
-
// If it does exist, we just check it out
|
|
103
|
-
const createBranchFlag = targetBranchExists ? '' : `-b ${branchName}`;
|
|
104
101
|
try {
|
|
105
102
|
await fs.ensureDir(path.dirname(wtPath));
|
|
106
|
-
// slightly different logic for creating new branch vs existing
|
|
107
103
|
if (targetBranchExists) {
|
|
104
|
+
// Branch exists β just attach the worktree
|
|
108
105
|
await execa('git', ['worktree', 'add', wtPath, branchName]);
|
|
109
106
|
}
|
|
110
107
|
else {
|
|
111
|
-
|
|
108
|
+
// Create branch WITHOUT tracking the base, then attach worktree
|
|
109
|
+
await execa('git', ['branch', '--no-track', branchName, baseRef]);
|
|
110
|
+
await execa('git', ['worktree', 'add', wtPath, branchName]);
|
|
112
111
|
}
|
|
113
112
|
}
|
|
114
113
|
catch (e) {
|
|
115
114
|
spinner.fail('Failed to create worktree.');
|
|
116
115
|
const cmd = targetBranchExists
|
|
117
116
|
? `git worktree add ${wtPath} ${branchName}`
|
|
118
|
-
: `git
|
|
117
|
+
: `git branch --no-track ${branchName} ${baseRef} && git worktree add ${wtPath} ${branchName}`;
|
|
119
118
|
log.actionableError(e.message, cmd, wtPath, [
|
|
120
119
|
'Check if the folder already exists: ls ' + wtPath,
|
|
121
120
|
'Check if the branch is already used: git worktree list',
|
|
@@ -124,20 +123,21 @@ export async function createCommandNew(options) {
|
|
|
124
123
|
]);
|
|
125
124
|
return;
|
|
126
125
|
}
|
|
126
|
+
// Safety net: ensure no incorrect upstream was inherited
|
|
127
|
+
spinner.text = 'Verifying upstream safety...';
|
|
128
|
+
await ensureCorrectUpstream(wtPath, branchName);
|
|
129
|
+
// Auto-publish: push to origin and set correct tracking
|
|
127
130
|
try {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
await ensureCorrectUpstream(wtPath, branchName);
|
|
131
|
+
spinner.text = 'Publishing branch...';
|
|
132
|
+
await publishBranch(wtPath, branchName);
|
|
131
133
|
spinner.succeed('Worktree created and branch published.');
|
|
132
134
|
}
|
|
133
135
|
catch (e) {
|
|
134
|
-
spinner.
|
|
136
|
+
spinner.succeed('Worktree created (publish failed β push manually later).');
|
|
135
137
|
log.actionableError(e.message, 'git push -u origin HEAD', wtPath, [
|
|
136
138
|
`cd ${wtPath}`,
|
|
137
139
|
'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
|
]);
|
|
140
|
-
// We don't return here because the worktree IS created, we just failed to publish
|
|
141
141
|
}
|
|
142
142
|
if (shouldBootstrap) {
|
|
143
143
|
await runBootstrap(wtPath, repoRoot);
|
|
@@ -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, ensureCorrectUpstream } from '../../lib/git.js';
|
|
4
|
+
import { getRepoRoot, getRepoName, verifyRef, fetchAll, getCurrentBranch, ensureCorrectUpstream, publishBranch } 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';
|
|
@@ -82,14 +82,16 @@ export async function createCommandMulti(options) {
|
|
|
82
82
|
await execa('git', ['worktree', 'add', wtPath, branchName]);
|
|
83
83
|
}
|
|
84
84
|
else {
|
|
85
|
-
|
|
85
|
+
// Create branch WITHOUT tracking the base, then attach worktree
|
|
86
|
+
await execa('git', ['branch', '--no-track', branchName, baseRef]);
|
|
87
|
+
await execa('git', ['worktree', 'add', wtPath, branchName]);
|
|
86
88
|
}
|
|
87
89
|
}
|
|
88
90
|
catch (error) {
|
|
89
91
|
wtSpinner.fail(`Failed to create worktree for ${branchName}.`);
|
|
90
92
|
const cmd = targetBranchExists
|
|
91
93
|
? `git worktree add ${wtPath} ${branchName}`
|
|
92
|
-
: `git
|
|
94
|
+
: `git branch --no-track ${branchName} ${baseRef} && git worktree add ${wtPath} ${branchName}`;
|
|
93
95
|
log.actionableError(error.message, cmd, wtPath, [
|
|
94
96
|
'Check if the folder already exists: ls ' + wtPath,
|
|
95
97
|
'Check if the branch is already used: git worktree list',
|
|
@@ -98,22 +100,23 @@ export async function createCommandMulti(options) {
|
|
|
98
100
|
]);
|
|
99
101
|
continue;
|
|
100
102
|
}
|
|
103
|
+
// Safety net: ensure no incorrect upstream was inherited
|
|
104
|
+
wtSpinner.text = `Verifying upstream safety for ${branchName}...`;
|
|
105
|
+
await ensureCorrectUpstream(wtPath, branchName);
|
|
106
|
+
// Auto-publish: push to origin and set correct tracking
|
|
101
107
|
try {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
await ensureCorrectUpstream(wtPath, branchName);
|
|
108
|
+
wtSpinner.text = `Publishing branch ${branchName}...`;
|
|
109
|
+
await publishBranch(wtPath, branchName);
|
|
105
110
|
wtSpinner.succeed(`Worktree for ${chalk.cyan(branchName)} created and published.`);
|
|
106
|
-
createdWorktrees.push(wtPath);
|
|
107
111
|
}
|
|
108
112
|
catch (error) {
|
|
109
|
-
wtSpinner.
|
|
113
|
+
wtSpinner.succeed(`Worktree for ${chalk.cyan(branchName)} created (publish failed β push manually later).`);
|
|
110
114
|
log.actionableError(error.message, 'git push -u origin HEAD', wtPath, [
|
|
111
115
|
`cd ${wtPath}`,
|
|
112
116
|
'Attempt to push manually: git push -u origin HEAD',
|
|
113
|
-
'Check if the remote branch already exists or if you have push permissions'
|
|
114
117
|
]);
|
|
115
|
-
createdWorktrees.push(wtPath); // Still added to list since wt exists
|
|
116
118
|
}
|
|
119
|
+
createdWorktrees.push(wtPath);
|
|
117
120
|
// 4. Bootstrap
|
|
118
121
|
if (shouldBootstrap) {
|
|
119
122
|
await runBootstrap(wtPath, repoRoot);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import inquirer from 'inquirer';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
import { listWorktrees, removeWorktree, getRepoRoot } from '../../lib/git.js';
|
|
4
|
+
import { listWorktrees, removeWorktree, getRepoRoot, getLastActivity } from '../../lib/git.js';
|
|
5
5
|
import { WORKTREES_ROOT } from '../../lib/paths.js';
|
|
6
|
-
import { log, createSpinner } from '../../lib/ui.js';
|
|
6
|
+
import { log, createSpinner, timeAgo } from '../../lib/ui.js';
|
|
7
7
|
export async function deleteCommand() {
|
|
8
8
|
try {
|
|
9
9
|
const _ = await getRepoRoot();
|
|
@@ -14,10 +14,13 @@ export async function deleteCommand() {
|
|
|
14
14
|
log.info('No managed worktrees found to delete.');
|
|
15
15
|
return;
|
|
16
16
|
}
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// Pre-fetch activity for all managed worktrees in parallel
|
|
18
|
+
const activities = await Promise.all(managedWts.map(wt => getLastActivity(wt.path)));
|
|
19
|
+
const choices = managedWts.map((wt, i) => {
|
|
20
|
+
const branchName = wt.branch || wt.HEAD || 'detached';
|
|
21
|
+
const active = activities[i] ? chalk.magenta(timeAgo(activities[i])) : chalk.dim('β');
|
|
19
22
|
return {
|
|
20
|
-
name: `${chalk.bold(
|
|
23
|
+
name: `${chalk.bold.yellow(branchName)} ${chalk.dim('Β·')} ${active}`,
|
|
21
24
|
value: wt.path,
|
|
22
25
|
};
|
|
23
26
|
});
|
package/dist/commands/wt/list.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import
|
|
3
|
-
import { listWorktrees, getRepoRoot, isGitClean } from '../../lib/git.js';
|
|
2
|
+
import { listWorktrees, getRepoRoot, isGitClean, getLastActivity } from '../../lib/git.js';
|
|
4
3
|
import { WORKTREES_ROOT } from '../../lib/paths.js';
|
|
5
|
-
import { log } from '../../lib/ui.js';
|
|
4
|
+
import { log, timeAgo } from '../../lib/ui.js';
|
|
6
5
|
export async function listCommand() {
|
|
7
6
|
try {
|
|
8
7
|
const _ = await getRepoRoot(); // Verify we are in a git repo
|
|
@@ -13,21 +12,22 @@ export async function listCommand() {
|
|
|
13
12
|
}
|
|
14
13
|
console.log(chalk.bold('\n Active Worktrees:\n'));
|
|
15
14
|
// Header
|
|
16
|
-
console.log(` ${chalk.dim('TYPE')} ${chalk.dim('STATE')} ${chalk.dim('
|
|
17
|
-
console.log(chalk.dim(' ' + '-'.repeat(
|
|
15
|
+
console.log(` ${chalk.dim('TYPE')} ${chalk.dim('STATE')} ${chalk.dim('LAST ACTIVE')} ${chalk.dim('BRANCH')}`);
|
|
16
|
+
console.log(chalk.dim(' ' + '-'.repeat(70)));
|
|
18
17
|
for (const wt of worktrees) {
|
|
19
18
|
const isManaged = wt.path.startsWith(WORKTREES_ROOT);
|
|
20
19
|
const type = isManaged ? chalk.green('MANAGED') : chalk.blue('MAIN ');
|
|
21
20
|
const branchName = wt.branch || wt.HEAD || 'detached';
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const isClean = await isGitClean(wt.path);
|
|
21
|
+
// Fetch state and activity in parallel
|
|
22
|
+
const [isClean, lastActive] = await Promise.all([
|
|
23
|
+
isGitClean(wt.path),
|
|
24
|
+
getLastActivity(wt.path),
|
|
25
|
+
]);
|
|
28
26
|
const stateLabel = (isClean ? 'clean' : 'dirty').padEnd(8);
|
|
29
27
|
const stateText = isClean ? chalk.green(stateLabel) : chalk.yellow(stateLabel);
|
|
30
|
-
|
|
28
|
+
const activeLabel = lastActive ? timeAgo(lastActive) : 'β';
|
|
29
|
+
const activeText = chalk.magenta(activeLabel.padEnd(14));
|
|
30
|
+
console.log(` ${type} ${stateText} ${activeText} ${chalk.yellow(branchName)}`);
|
|
31
31
|
}
|
|
32
32
|
console.log('');
|
|
33
33
|
}
|
package/dist/lib/config.js
CHANGED
|
@@ -3,13 +3,26 @@ import fs from 'fs-extra';
|
|
|
3
3
|
import { execa } from 'execa';
|
|
4
4
|
import { log, createSpinner } from './ui.js';
|
|
5
5
|
export async function getBootstrapCommands(repoRoot, wtPath) {
|
|
6
|
-
|
|
7
|
-
if
|
|
6
|
+
// repoRoot first (source of truth β where yggtree is run from)
|
|
7
|
+
// wtPath second (per-worktree override if needed)
|
|
8
|
+
const searchPaths = [repoRoot];
|
|
9
|
+
if (wtPath && wtPath !== repoRoot)
|
|
8
10
|
searchPaths.push(wtPath);
|
|
9
|
-
searchPaths.push(repoRoot);
|
|
10
11
|
for (const searchPath of searchPaths) {
|
|
12
|
+
const yggtreeConfigPath = path.join(searchPath, '.yggtree', 'worktree-setup.json');
|
|
11
13
|
const configPath = path.join(searchPath, 'yggtree-worktree.json');
|
|
12
14
|
const cursorConfigPath = path.join(searchPath, '.cursor', 'worktrees.json');
|
|
15
|
+
if (await fs.pathExists(yggtreeConfigPath)) {
|
|
16
|
+
try {
|
|
17
|
+
const config = await fs.readJSON(yggtreeConfigPath);
|
|
18
|
+
if (config['setup-worktree'] && Array.isArray(config['setup-worktree'])) {
|
|
19
|
+
return config['setup-worktree'];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
log.warning(`Failed to parse ${yggtreeConfigPath}.`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
13
26
|
if (await fs.pathExists(configPath)) {
|
|
14
27
|
try {
|
|
15
28
|
const config = await fs.readJSON(configPath);
|
|
@@ -50,7 +63,7 @@ export async function runBootstrap(wtPath, repoRoot) {
|
|
|
50
63
|
spinner.fail(`Failed: ${cmd}`);
|
|
51
64
|
log.actionableError(e.message, cmd, wtPath, [
|
|
52
65
|
`Try running the command manually: cd ${wtPath} && ${cmd}`,
|
|
53
|
-
'Check your configuration in yggtree-worktree.json'
|
|
66
|
+
'Check your configuration in .yggtree/worktree-setup.json or yggtree-worktree.json'
|
|
54
67
|
]);
|
|
55
68
|
}
|
|
56
69
|
}
|
package/dist/lib/git.js
CHANGED
|
@@ -86,9 +86,12 @@ export async function isGitClean(cwd) {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
89
|
+
* Safety net: ensures the branch does NOT track an incorrect upstream
|
|
90
|
+
* (e.g. origin/main when the branch is feat/new-thing).
|
|
91
|
+
*
|
|
92
|
+
* - Correct tracking (origin/<branchName>) β no-op
|
|
93
|
+
* - No tracking at all β no-op
|
|
94
|
+
* - Wrong tracking β unsets it
|
|
92
95
|
*/
|
|
93
96
|
export async function ensureCorrectUpstream(wtPath, branchName) {
|
|
94
97
|
const desiredUpstream = `origin/${branchName}`;
|
|
@@ -98,21 +101,68 @@ export async function ensureCorrectUpstream(wtPath, branchName) {
|
|
|
98
101
|
currentUpstream = stdout.trim();
|
|
99
102
|
}
|
|
100
103
|
catch {
|
|
101
|
-
// No upstream set
|
|
104
|
+
// No upstream set β safe
|
|
105
|
+
return;
|
|
102
106
|
}
|
|
103
107
|
if (currentUpstream === desiredUpstream) {
|
|
104
108
|
return; // Already correct
|
|
105
109
|
}
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
// Wrong tracking β kill it
|
|
111
|
+
log.warning(`Incorrect upstream detected: ${chalk.red(currentUpstream)} (expected ${chalk.cyan(desiredUpstream)}). Unsetting...`);
|
|
112
|
+
await execa('git', ['branch', '--unset-upstream'], { cwd: wtPath });
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Publishes a local branch to origin and sets upstream tracking.
|
|
116
|
+
* Skips if the branch is already published (origin/<branchName> exists and is tracked).
|
|
117
|
+
*/
|
|
118
|
+
export async function publishBranch(wtPath, branchName) {
|
|
119
|
+
const desiredUpstream = `origin/${branchName}`;
|
|
120
|
+
// Check if already tracking the correct remote
|
|
121
|
+
try {
|
|
122
|
+
const { stdout } = await execa('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], { cwd: wtPath });
|
|
123
|
+
if (stdout.trim() === desiredUpstream) {
|
|
124
|
+
return; // Already published and tracked
|
|
125
|
+
}
|
|
110
126
|
}
|
|
111
|
-
|
|
112
|
-
|
|
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.`);
|
|
127
|
+
catch {
|
|
128
|
+
// No upstream β expected for new branches
|
|
115
129
|
}
|
|
116
|
-
log.info(`Publishing
|
|
130
|
+
log.info(`Publishing ${chalk.cyan(branchName)} β ${chalk.cyan(desiredUpstream)}...`);
|
|
117
131
|
await execa('git', ['push', '-u', 'origin', 'HEAD'], { cwd: wtPath });
|
|
118
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Returns the most recent activity date for a worktree by checking two signals:
|
|
135
|
+
* 1. Last commit time (captures committed work)
|
|
136
|
+
* 2. Git index mtime (captures staging, checkouts, uncommitted work)
|
|
137
|
+
*
|
|
138
|
+
* The most recent of the two wins. Returns null if both fail.
|
|
139
|
+
*/
|
|
140
|
+
export async function getLastActivity(wtPath) {
|
|
141
|
+
const dates = [];
|
|
142
|
+
// Signal 1 β last commit epoch
|
|
143
|
+
try {
|
|
144
|
+
const { stdout } = await execa('git', ['log', '-1', '--format=%ct'], { cwd: wtPath });
|
|
145
|
+
const epoch = parseInt(stdout.trim(), 10);
|
|
146
|
+
if (!isNaN(epoch)) {
|
|
147
|
+
dates.push(new Date(epoch * 1000));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// no commits reachable β skip
|
|
152
|
+
}
|
|
153
|
+
// Signal 2 β git index file mtime
|
|
154
|
+
try {
|
|
155
|
+
const { stdout: gitDir } = await execa('git', ['rev-parse', '--git-dir'], { cwd: wtPath });
|
|
156
|
+
const gitDirPath = gitDir.trim();
|
|
157
|
+
const resolvedGitDir = path.isAbsolute(gitDirPath) ? gitDirPath : path.join(wtPath, gitDirPath);
|
|
158
|
+
const indexPath = path.join(resolvedGitDir, 'index');
|
|
159
|
+
const stat = await fs.stat(indexPath);
|
|
160
|
+
dates.push(stat.mtime);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// index not found β skip
|
|
164
|
+
}
|
|
165
|
+
if (dates.length === 0)
|
|
166
|
+
return null;
|
|
167
|
+
return new Date(Math.max(...dates.map(d => d.getTime())));
|
|
168
|
+
}
|
package/dist/lib/sandbox.js
CHANGED
|
@@ -1,26 +1,28 @@
|
|
|
1
1
|
import crypto from 'crypto';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import fs from 'fs-extra';
|
|
4
|
-
export const
|
|
4
|
+
export const YGGTREE_DIR = '.yggtree';
|
|
5
|
+
export const SANDBOX_META_FILE = 'sandbox-meta.json';
|
|
5
6
|
/**
|
|
6
7
|
* Generate a sandbox worktree name: <branch>_<4-char-hash>
|
|
7
8
|
*/
|
|
8
9
|
export function generateSandboxName(branch) {
|
|
9
10
|
const hash = crypto.randomBytes(2).toString('hex'); // 4 hex chars
|
|
10
11
|
const safeBranch = branch.replace(/[\\/]/g, '-');
|
|
11
|
-
return
|
|
12
|
+
return `sandbox-${hash}_${safeBranch}`;
|
|
12
13
|
}
|
|
13
14
|
/**
|
|
14
15
|
* Get the path to the sandbox metadata file
|
|
15
16
|
*/
|
|
16
17
|
export function getSandboxMetaPath(wtPath) {
|
|
17
|
-
return path.join(wtPath, SANDBOX_META_FILE);
|
|
18
|
+
return path.join(wtPath, YGGTREE_DIR, SANDBOX_META_FILE);
|
|
18
19
|
}
|
|
19
20
|
/**
|
|
20
21
|
* Write sandbox metadata to the worktree
|
|
21
22
|
*/
|
|
22
23
|
export async function writeSandboxMeta(wtPath, meta) {
|
|
23
24
|
const metaPath = getSandboxMetaPath(wtPath);
|
|
25
|
+
await fs.ensureDir(path.dirname(metaPath));
|
|
24
26
|
await fs.writeJSON(metaPath, meta, { spaces: 2 });
|
|
25
27
|
}
|
|
26
28
|
/**
|
package/dist/lib/ui.js
CHANGED
|
@@ -50,3 +50,23 @@ export const ui = {
|
|
|
50
50
|
code: (cmd) => chalk.bgBlack.white(` ${cmd} `),
|
|
51
51
|
path: (p) => chalk.cyan(p),
|
|
52
52
|
};
|
|
53
|
+
// --- Time helpers ---
|
|
54
|
+
/**
|
|
55
|
+
* Returns a human-friendly relative time string like "just now", "5 min ago", "3 days ago".
|
|
56
|
+
*/
|
|
57
|
+
export function timeAgo(date) {
|
|
58
|
+
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
|
59
|
+
if (seconds < 60)
|
|
60
|
+
return 'just now';
|
|
61
|
+
if (seconds < 3600)
|
|
62
|
+
return `${Math.floor(seconds / 60)} min ago`;
|
|
63
|
+
if (seconds < 86400)
|
|
64
|
+
return `${Math.floor(seconds / 3600)}h ago`;
|
|
65
|
+
if (seconds < 604800)
|
|
66
|
+
return `${Math.floor(seconds / 86400)}d ago`;
|
|
67
|
+
if (seconds < 2592000)
|
|
68
|
+
return `${Math.floor(seconds / 604800)}w ago`;
|
|
69
|
+
if (seconds < 31536000)
|
|
70
|
+
return `${Math.floor(seconds / 2592000)}mo ago`;
|
|
71
|
+
return `${Math.floor(seconds / 31536000)}y ago`;
|
|
72
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { log } from '../../lib/ui.js';
|
|
2
|
-
export async function leaveCommand() {
|
|
3
|
-
if (process.env.YGGTREE_SHELL === 'true') {
|
|
4
|
-
log.info('Leaving worktree sub-shell...');
|
|
5
|
-
// SIGHUP is standard for closing shells
|
|
6
|
-
process.kill(process.ppid, 'SIGHUP');
|
|
7
|
-
}
|
|
8
|
-
else {
|
|
9
|
-
log.warning('You are not in a yggtree sub-shell.');
|
|
10
|
-
log.dim('Try using "yggtree wt enter" first.');
|
|
11
|
-
process.exit(0);
|
|
12
|
-
}
|
|
13
|
-
}
|