worktree-flow 0.0.14 → 0.0.15
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 +2 -4
- package/dist/cli.js +2 -0
- package/dist/commands/branch.js +25 -2
- package/dist/commands/config.js +2 -1
- package/dist/commands/helpers.js +14 -0
- package/dist/commands/list.js +2 -9
- package/dist/commands/quickstart.js +161 -0
- package/dist/commands/status.js +2 -3
- package/dist/lib/config.js +11 -1
- package/dist/lib/fetchCache.js +47 -9
- package/dist/lib/workspaceConfig.js +9 -1
- package/dist/lib/workspaceDirectory.js +19 -2
- package/dist/usecases/checkoutWorkspace.js +1 -0
- package/dist/usecases/createBranchWorkspace.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,9 +22,8 @@ Stop juggling branches across repos. `flow` creates isolated workspaces with git
|
|
|
22
22
|
```bash
|
|
23
23
|
npm install -g worktree-flow
|
|
24
24
|
|
|
25
|
-
#
|
|
26
|
-
flow
|
|
27
|
-
flow config set dest-path ~/workspaces
|
|
25
|
+
# Guided setup wizard
|
|
26
|
+
flow quickstart
|
|
28
27
|
|
|
29
28
|
# Create a workspace with new branches
|
|
30
29
|
flow branch TICKET-123
|
|
@@ -104,7 +103,6 @@ Settings are stored in `~/.config/flow/config.json`.
|
|
|
104
103
|
|-----|-------------|---------|
|
|
105
104
|
| `source-path` | Directory containing your source repos | *required* |
|
|
106
105
|
| `dest-path` | Directory where workspaces are created | *required* |
|
|
107
|
-
| `main-branch` | Main branch name | `master` |
|
|
108
106
|
| `tmux` | Create tmux sessions with split panes (root + each worktree) | `false` |
|
|
109
107
|
| `copy-files` | Files to copy from source repos to worktrees | `.env` |
|
|
110
108
|
| `post-checkout` | Command to run after checkout (e.g. `npm ci`) | *none* |
|
package/dist/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ import { registerStatusCommand } from './commands/status.js';
|
|
|
11
11
|
import { registerPruneCommand } from './commands/prune.js';
|
|
12
12
|
import { registerFetchCommand } from './commands/fetch.js';
|
|
13
13
|
import { registerTmuxCommand } from './commands/tmux.js';
|
|
14
|
+
import { registerQuickstartCommand } from './commands/quickstart.js';
|
|
14
15
|
const program = new Command();
|
|
15
16
|
program
|
|
16
17
|
.name('flow')
|
|
@@ -27,4 +28,5 @@ registerStatusCommand(program);
|
|
|
27
28
|
registerPruneCommand(program);
|
|
28
29
|
registerFetchCommand(program);
|
|
29
30
|
registerTmuxCommand(program);
|
|
31
|
+
registerQuickstartCommand(program);
|
|
30
32
|
program.parse();
|
package/dist/commands/branch.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import checkbox, { Separator } from '@inquirer/checkbox';
|
|
2
3
|
import input from '@inquirer/input';
|
|
3
4
|
import confirm from '@inquirer/confirm';
|
|
4
5
|
import chalk from 'chalk';
|
|
@@ -13,10 +14,30 @@ export async function runBranch(branchName, useCases, services, deps) {
|
|
|
13
14
|
throw new NoReposFoundError(sourcePath);
|
|
14
15
|
}
|
|
15
16
|
// User prompts
|
|
17
|
+
const choices = services.repos.formatRepoChoices(repos).map((choice) => ({
|
|
18
|
+
...choice,
|
|
19
|
+
checked: config.branchAutoSelectRepos.includes(choice.name),
|
|
20
|
+
}));
|
|
21
|
+
const recentlyUsed = new Set(services.fetchCache.getRecentlyUsedRepos(8));
|
|
22
|
+
const commonlyUsed = choices.filter((c) => recentlyUsed.has(c.name));
|
|
23
|
+
let checkboxChoices;
|
|
24
|
+
if (commonlyUsed.length > 0) {
|
|
25
|
+
const commonlyUsedNames = new Set(commonlyUsed.map((c) => c.name));
|
|
26
|
+
const remaining = choices.filter((c) => !commonlyUsedNames.has(c.name));
|
|
27
|
+
checkboxChoices = [
|
|
28
|
+
new Separator('Recently Used'),
|
|
29
|
+
...commonlyUsed,
|
|
30
|
+
...(remaining.length > 0 ? [new Separator(), ...remaining] : []),
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
checkboxChoices = choices;
|
|
35
|
+
}
|
|
16
36
|
const selected = await deps.checkbox({
|
|
17
37
|
message: `Select repos for branch "${branchName}":`,
|
|
18
|
-
choices:
|
|
38
|
+
choices: checkboxChoices,
|
|
19
39
|
pageSize: 20,
|
|
40
|
+
loop: false,
|
|
20
41
|
});
|
|
21
42
|
if (selected.length === 0) {
|
|
22
43
|
services.console.log('No repos selected.');
|
|
@@ -50,6 +71,8 @@ export async function runBranch(branchName, useCases, services, deps) {
|
|
|
50
71
|
postCheckout: shouldRunPostCheckout ? config.postCheckout : undefined,
|
|
51
72
|
perRepoPostCheckout: shouldRunPostCheckout ? config.perRepoPostCheckout : {},
|
|
52
73
|
});
|
|
74
|
+
// Track which repos were branched from
|
|
75
|
+
services.fetchCache.trackBranchUsage(selected.map((r) => path.basename(r)));
|
|
53
76
|
// Display results
|
|
54
77
|
services.console.log(`\nCreated workspace at ${chalk.cyan(result.workspacePath)} with ${result.successCount}/${result.totalCount} repos.`);
|
|
55
78
|
if (result.tmuxCreated) {
|
package/dist/commands/config.js
CHANGED
|
@@ -8,7 +8,7 @@ export function registerConfigCommand(program) {
|
|
|
8
8
|
.description('Manage flow configuration');
|
|
9
9
|
configCmd
|
|
10
10
|
.command('set <key> <value>')
|
|
11
|
-
.description('Set a config value (source-path, dest-path, copy-files, tmux,
|
|
11
|
+
.description('Set a config value (source-path, dest-path, copy-files, tmux, post-checkout)')
|
|
12
12
|
.action((key, value) => {
|
|
13
13
|
const services = createServices();
|
|
14
14
|
if (!isValidKey(key)) {
|
|
@@ -34,6 +34,7 @@ export function registerConfigCommand(program) {
|
|
|
34
34
|
});
|
|
35
35
|
configCmd
|
|
36
36
|
.command('list')
|
|
37
|
+
.alias('ls')
|
|
37
38
|
.description('List all config options and their current values')
|
|
38
39
|
.action(() => {
|
|
39
40
|
const services = createServices();
|
package/dist/commands/helpers.js
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import { StatusService } from '../lib/status.js';
|
|
3
|
+
/**
|
|
4
|
+
* Format a single repo's status as a display line, consistent across list and status commands.
|
|
5
|
+
*/
|
|
6
|
+
export function formatRepoStatusLine(repoName, status, baseBranch) {
|
|
7
|
+
const statusMessage = StatusService.getStatusMessage(status, baseBranch);
|
|
8
|
+
const hasIssues = StatusService.hasIssues(status);
|
|
9
|
+
const indicator = hasIssues ? chalk.red('✗') : chalk.green('✓');
|
|
10
|
+
const message = hasIssues ? chalk.red(statusMessage) : chalk.green(statusMessage);
|
|
11
|
+
const trackingInfo = status.upstreamBranch
|
|
12
|
+
? chalk.dim(` → ${status.upstreamBranch}`)
|
|
13
|
+
: chalk.dim(' (no upstream)');
|
|
14
|
+
return ` ${indicator} ${chalk.yellow(repoName)}: ${message}${trackingInfo}`;
|
|
15
|
+
}
|
|
2
16
|
/**
|
|
3
17
|
* Get a human-readable status indicator for a workspace based on its worktree statuses.
|
|
4
18
|
*/
|
package/dist/commands/list.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { createServices } from '../lib/services.js';
|
|
3
3
|
import { createUseCases } from '../usecases/usecases.js';
|
|
4
|
-
import {
|
|
4
|
+
import { formatRepoStatusLine } from './helpers.js';
|
|
5
5
|
export async function runList(useCases, services) {
|
|
6
6
|
const { destPath, sourcePath } = services.config.getRequired();
|
|
7
7
|
const config = services.config.load();
|
|
@@ -54,14 +54,7 @@ export async function runList(useCases, services) {
|
|
|
54
54
|
// Display each repo with its status and tracking branch
|
|
55
55
|
for (const { repoName, status } of workspace.statuses) {
|
|
56
56
|
const baseBranch = getBaseBranch(repoName);
|
|
57
|
-
|
|
58
|
-
const hasIssues = StatusService.hasIssues(status);
|
|
59
|
-
const indicator = hasIssues ? chalk.red('✗') : chalk.green('✓');
|
|
60
|
-
const message = hasIssues ? chalk.red(statusMessage) : chalk.green(statusMessage);
|
|
61
|
-
const trackingInfo = status.upstreamBranch
|
|
62
|
-
? chalk.dim(` → ${status.upstreamBranch}`)
|
|
63
|
-
: chalk.dim(' (no upstream)');
|
|
64
|
-
services.console.log(` ${indicator} ${chalk.yellow(repoName)}: ${message}${trackingInfo}`);
|
|
57
|
+
services.console.log(formatRepoStatusLine(repoName, status, baseBranch));
|
|
65
58
|
}
|
|
66
59
|
services.console.log(''); // Blank line between workspaces
|
|
67
60
|
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import input from '@inquirer/input';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { createServices } from '../lib/services.js';
|
|
6
|
+
import { NodeShell } from '../adapters/node.js';
|
|
7
|
+
const DIVIDER = chalk.dim(' ─────────────────────────────────────────────────');
|
|
8
|
+
const DEFAULT_DEST_PATH = '~/dev/workspaces';
|
|
9
|
+
const MAX_NAMES_SHOWN = 3;
|
|
10
|
+
function expandTilde(p) {
|
|
11
|
+
return p.replace(/^~/, os.homedir());
|
|
12
|
+
}
|
|
13
|
+
function summariseNames(names, noun) {
|
|
14
|
+
const count = names.length;
|
|
15
|
+
if (count === 0)
|
|
16
|
+
return `no ${noun}s found`;
|
|
17
|
+
const shown = names.slice(0, MAX_NAMES_SHOWN).join(', ');
|
|
18
|
+
if (count <= MAX_NAMES_SHOWN)
|
|
19
|
+
return `${count} ${count === 1 ? noun : noun + 's'}: ${shown}`;
|
|
20
|
+
return `${count} ${noun}s including ${shown}`;
|
|
21
|
+
}
|
|
22
|
+
async function isTmuxInstalled(shell) {
|
|
23
|
+
try {
|
|
24
|
+
await shell.execFile('which', ['tmux']);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export async function runQuickstart(services, deps) {
|
|
32
|
+
const existing = services.config.loadRaw();
|
|
33
|
+
services.console.log('');
|
|
34
|
+
services.console.log(chalk.bold(' Welcome to flow!'));
|
|
35
|
+
services.console.log('');
|
|
36
|
+
services.console.log(' flow creates isolated workspaces with git worktrees across multiple');
|
|
37
|
+
services.console.log(' repos — work on a feature branch in all your repos at once.');
|
|
38
|
+
services.console.log('');
|
|
39
|
+
services.console.log(DIVIDER);
|
|
40
|
+
services.console.log(chalk.bold(' Required'));
|
|
41
|
+
services.console.log(DIVIDER);
|
|
42
|
+
services.console.log('');
|
|
43
|
+
// Loop until user provides a source path that contains git repositories
|
|
44
|
+
services.console.log(chalk.dim(' Source directory containing your repos (e.g. ~/dev)'));
|
|
45
|
+
let sourcePath;
|
|
46
|
+
while (true) {
|
|
47
|
+
const sourcePathRaw = await deps.input({
|
|
48
|
+
message: 'Where are your git repositories?',
|
|
49
|
+
default: existing['source-path'] ?? '',
|
|
50
|
+
prefill: 'editable',
|
|
51
|
+
});
|
|
52
|
+
const resolved = path.resolve(expandTilde(sourcePathRaw));
|
|
53
|
+
try {
|
|
54
|
+
const repos = services.repos.discoverRepos(resolved);
|
|
55
|
+
if (repos.length === 0) {
|
|
56
|
+
services.console.log(chalk.yellow(' ⚠ No git repositories found — please try a different path \n'));
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const names = repos.map(r => path.basename(r));
|
|
60
|
+
services.console.log(chalk.dim(` Found ${summariseNames(names, 'repository')}`));
|
|
61
|
+
sourcePath = resolved;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
const msg = err.code === 'ENOENT'
|
|
66
|
+
? ' ⚠ Directory not found — please try again \n'
|
|
67
|
+
: ' ⚠ Could not read directory — please try again \n';
|
|
68
|
+
services.console.log(chalk.yellow(msg));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
services.console.log('');
|
|
72
|
+
services.console.log(chalk.dim(' Each branch gets its own folder here — should be an empty directory (e.g. ~/dev/workspaces)'));
|
|
73
|
+
const destPathRaw = await deps.input({
|
|
74
|
+
message: 'Where should workspaces be created?',
|
|
75
|
+
default: existing['dest-path'] ?? DEFAULT_DEST_PATH,
|
|
76
|
+
prefill: 'editable',
|
|
77
|
+
});
|
|
78
|
+
const destPath = path.resolve(expandTilde(destPathRaw));
|
|
79
|
+
const workspaces = services.workspaceDir.listWorkspaces(destPath);
|
|
80
|
+
if (workspaces.length > 0) {
|
|
81
|
+
const names = workspaces.map(w => w.name);
|
|
82
|
+
services.console.log(chalk.dim(` Found ${summariseNames(names, 'workspace')}`));
|
|
83
|
+
}
|
|
84
|
+
services.console.log('');
|
|
85
|
+
services.console.log(DIVIDER);
|
|
86
|
+
services.console.log(chalk.bold(' Optional') + chalk.dim(' (press Enter to skip)'));
|
|
87
|
+
services.console.log(DIVIDER);
|
|
88
|
+
services.console.log('');
|
|
89
|
+
services.console.log(chalk.dim(' Runs in each worktree after a workspace is created'));
|
|
90
|
+
const postCheckout = await deps.input({
|
|
91
|
+
message: 'Post-checkout command?',
|
|
92
|
+
default: existing['post-checkout'] ?? '',
|
|
93
|
+
prefill: 'editable',
|
|
94
|
+
});
|
|
95
|
+
let tmuxEnabled = existing['tmux'] === 'true';
|
|
96
|
+
const tmuxAvailable = await isTmuxInstalled(deps.shell);
|
|
97
|
+
if (tmuxAvailable) {
|
|
98
|
+
services.console.log('');
|
|
99
|
+
services.console.log(chalk.dim(' Opens a tmux session with split panes, one per worktree'));
|
|
100
|
+
const tmuxRaw = await deps.input({
|
|
101
|
+
message: 'Enable tmux integration (yes/no)?',
|
|
102
|
+
default: existing['tmux'] === 'true' ? 'yes' : 'no',
|
|
103
|
+
prefill: 'editable',
|
|
104
|
+
});
|
|
105
|
+
tmuxEnabled = /^(y|yes)$/i.test(tmuxRaw.trim());
|
|
106
|
+
}
|
|
107
|
+
// All-or-nothing save — only reached if all prompts complete without throwing
|
|
108
|
+
const newConfig = {
|
|
109
|
+
...existing,
|
|
110
|
+
'source-path': sourcePath,
|
|
111
|
+
'dest-path': destPath,
|
|
112
|
+
'tmux': tmuxEnabled ? 'true' : 'false',
|
|
113
|
+
};
|
|
114
|
+
if (postCheckout) {
|
|
115
|
+
newConfig['post-checkout'] = postCheckout;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
delete newConfig['post-checkout'];
|
|
119
|
+
}
|
|
120
|
+
services.config.saveRaw(newConfig);
|
|
121
|
+
// Summary
|
|
122
|
+
services.console.log('');
|
|
123
|
+
services.console.log(DIVIDER);
|
|
124
|
+
services.console.log('');
|
|
125
|
+
services.console.log(chalk.green(' ✓ Configuration saved!'));
|
|
126
|
+
services.console.log('');
|
|
127
|
+
services.console.log(` ${chalk.cyan('source-path')} ${sourcePath}`);
|
|
128
|
+
services.console.log(` ${chalk.cyan('dest-path')} ${destPath}`);
|
|
129
|
+
if (postCheckout) {
|
|
130
|
+
services.console.log(` ${chalk.cyan('post-checkout')} ${postCheckout}`);
|
|
131
|
+
}
|
|
132
|
+
services.console.log(` ${chalk.cyan('tmux')} ${tmuxEnabled}`);
|
|
133
|
+
services.console.log('');
|
|
134
|
+
services.console.log(chalk.bold(' Get started:'));
|
|
135
|
+
services.console.log('');
|
|
136
|
+
services.console.log(` ${chalk.cyan('flow branch my-feature')} Create a new branch across repos`);
|
|
137
|
+
services.console.log(` ${chalk.cyan('flow checkout my-feature')} Checkout an existing branch`);
|
|
138
|
+
services.console.log(` ${chalk.cyan('flow list')} See all your workspaces`);
|
|
139
|
+
services.console.log('');
|
|
140
|
+
services.console.log(chalk.dim(' Run `flow config list` to see all settings.'));
|
|
141
|
+
services.console.log('');
|
|
142
|
+
}
|
|
143
|
+
export function registerQuickstartCommand(program) {
|
|
144
|
+
program
|
|
145
|
+
.command('quickstart')
|
|
146
|
+
.description('Interactive setup wizard for first-time configuration')
|
|
147
|
+
.action(async () => {
|
|
148
|
+
const services = createServices();
|
|
149
|
+
try {
|
|
150
|
+
await runQuickstart(services, { input, shell: new NodeShell() });
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
// Suppress ExitPromptError — user Ctrl-C'd a prompt, exit silently
|
|
154
|
+
if (error?.name === 'ExitPromptError') {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
services.console.error(error.message);
|
|
158
|
+
services.process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
package/dist/commands/status.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createServices } from '../lib/services.js';
|
|
|
3
3
|
import { createUseCases } from '../usecases/usecases.js';
|
|
4
4
|
import { StatusService } from '../lib/status.js';
|
|
5
5
|
import { resolveWorkspace } from '../lib/workspaceResolver.js';
|
|
6
|
+
import { formatRepoStatusLine } from './helpers.js';
|
|
6
7
|
export async function runStatus(branchName, useCases, services) {
|
|
7
8
|
const { sourcePath } = services.config.getRequired();
|
|
8
9
|
const config = services.config.load();
|
|
@@ -31,13 +32,11 @@ export async function runStatus(branchName, useCases, services) {
|
|
|
31
32
|
let issuesCount = 0;
|
|
32
33
|
for (const { repoName, status } of result.statuses) {
|
|
33
34
|
const baseBranch = getBaseBranch(repoName);
|
|
34
|
-
|
|
35
|
+
services.console.log(formatRepoStatusLine(repoName, status, baseBranch));
|
|
35
36
|
if (StatusService.hasIssues(status)) {
|
|
36
|
-
services.console.log(` ${chalk.red('✗')} ${repoName}: ${chalk.red(message)}`);
|
|
37
37
|
issuesCount++;
|
|
38
38
|
}
|
|
39
39
|
else {
|
|
40
|
-
services.console.log(` ${chalk.green('✓')} ${repoName}: ${chalk.green(message)}`);
|
|
41
40
|
cleanCount++;
|
|
42
41
|
}
|
|
43
42
|
}
|
package/dist/lib/config.js
CHANGED
|
@@ -11,6 +11,7 @@ const RawConfigSchema = z.object({
|
|
|
11
11
|
'post-checkout': z.string().optional(),
|
|
12
12
|
'fetch-cache-ttl-seconds': z.string().optional(),
|
|
13
13
|
'per-repo-post-checkout': z.record(z.string(), z.string()).optional(),
|
|
14
|
+
'branch-auto-select-repos': z.string().optional(),
|
|
14
15
|
});
|
|
15
16
|
// Parsed config schema (with proper types)
|
|
16
17
|
const ParsedConfigSchema = z.object({
|
|
@@ -21,6 +22,7 @@ const ParsedConfigSchema = z.object({
|
|
|
21
22
|
postCheckout: z.string().optional(),
|
|
22
23
|
fetchCacheTtlSeconds: z.number().default(300),
|
|
23
24
|
perRepoPostCheckout: z.record(z.string(), z.string()).default({}),
|
|
25
|
+
branchAutoSelectRepos: z.array(z.string()).default([]),
|
|
24
26
|
});
|
|
25
27
|
// Required config schema (for getRequired)
|
|
26
28
|
const RequiredConfigSchema = z.object({
|
|
@@ -38,8 +40,9 @@ const ConfigValueSchemas = {
|
|
|
38
40
|
const num = parseInt(val, 10);
|
|
39
41
|
return !isNaN(num) && num >= 0;
|
|
40
42
|
}, { message: 'Must be a non-negative integer' }),
|
|
43
|
+
'branch-auto-select-repos': z.string(),
|
|
41
44
|
};
|
|
42
|
-
export const CONFIG_KEYS = ['source-path', 'dest-path', 'copy-files', 'tmux', 'post-checkout', 'fetch-cache-ttl-seconds'];
|
|
45
|
+
export const CONFIG_KEYS = ['source-path', 'dest-path', 'copy-files', 'tmux', 'post-checkout', 'fetch-cache-ttl-seconds', 'branch-auto-select-repos'];
|
|
43
46
|
/**
|
|
44
47
|
* Pure utility functions (no I/O)
|
|
45
48
|
*/
|
|
@@ -88,6 +91,9 @@ export class ConfigService {
|
|
|
88
91
|
? parseInt(raw['fetch-cache-ttl-seconds'], 10)
|
|
89
92
|
: undefined,
|
|
90
93
|
perRepoPostCheckout: raw['per-repo-post-checkout'],
|
|
94
|
+
branchAutoSelectRepos: raw['branch-auto-select-repos']
|
|
95
|
+
? raw['branch-auto-select-repos'].split(',').map((s) => s.trim()).filter(Boolean)
|
|
96
|
+
: undefined,
|
|
91
97
|
});
|
|
92
98
|
}
|
|
93
99
|
getRequired() {
|
|
@@ -134,6 +140,10 @@ export class ConfigService {
|
|
|
134
140
|
value: raw['fetch-cache-ttl-seconds'] ?? `${config.fetchCacheTtlSeconds} (default)`,
|
|
135
141
|
isDefault: !raw['fetch-cache-ttl-seconds'],
|
|
136
142
|
},
|
|
143
|
+
'branch-auto-select-repos': {
|
|
144
|
+
value: raw['branch-auto-select-repos'] ?? '(not set)',
|
|
145
|
+
isDefault: !raw['branch-auto-select-repos'],
|
|
146
|
+
},
|
|
137
147
|
perRepoPostCheckout: config.perRepoPostCheckout,
|
|
138
148
|
};
|
|
139
149
|
}
|
package/dist/lib/fetchCache.js
CHANGED
|
@@ -15,7 +15,7 @@ export class FetchCacheService {
|
|
|
15
15
|
return true; // Caching disabled
|
|
16
16
|
}
|
|
17
17
|
const cache = this.loadCache();
|
|
18
|
-
const cachedTimestamp = cache[repoPath];
|
|
18
|
+
const cachedTimestamp = cache.fetchTimestamps[repoPath];
|
|
19
19
|
if (!cachedTimestamp) {
|
|
20
20
|
return true; // Cache miss
|
|
21
21
|
}
|
|
@@ -31,7 +31,7 @@ export class FetchCacheService {
|
|
|
31
31
|
markFetched(repoPath) {
|
|
32
32
|
try {
|
|
33
33
|
const cache = this.loadCache();
|
|
34
|
-
cache[repoPath] = Date.now();
|
|
34
|
+
cache.fetchTimestamps[repoPath] = Date.now();
|
|
35
35
|
this.saveCache(cache);
|
|
36
36
|
}
|
|
37
37
|
catch (error) {
|
|
@@ -48,18 +48,58 @@ export class FetchCacheService {
|
|
|
48
48
|
filterReposToFetch(repoPaths, ttlSeconds) {
|
|
49
49
|
return repoPaths.filter(repoPath => this.shouldFetch(repoPath, ttlSeconds));
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Record that a branch was created from these repos (appends raw events)
|
|
53
|
+
* @param repoNames Array of repository names (basenames)
|
|
54
|
+
*/
|
|
55
|
+
trackBranchUsage(repoNames) {
|
|
56
|
+
try {
|
|
57
|
+
const cache = this.loadCache();
|
|
58
|
+
const date = new Date().toISOString();
|
|
59
|
+
for (const repo of repoNames) {
|
|
60
|
+
cache.branchRepoUsage.push({ repo, date });
|
|
61
|
+
}
|
|
62
|
+
this.saveCache(cache);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
// Don't block operations on cache write errors
|
|
66
|
+
console.warn('Warning: Failed to update branch usage cache:', error);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Get the most recently used repo names, derived from raw events
|
|
71
|
+
* @param limit Maximum number of repos to return
|
|
72
|
+
* @returns Repo names sorted by most recent usage, descending
|
|
73
|
+
*/
|
|
74
|
+
getRecentlyUsedRepos(limit) {
|
|
75
|
+
const events = this.loadCache().branchRepoUsage;
|
|
76
|
+
const latestByRepo = new Map();
|
|
77
|
+
for (const event of events) {
|
|
78
|
+
const existing = latestByRepo.get(event.repo);
|
|
79
|
+
if (!existing || event.date > existing) {
|
|
80
|
+
latestByRepo.set(event.repo, event.date);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return [...latestByRepo.entries()]
|
|
84
|
+
.sort((a, b) => b[1].localeCompare(a[1]))
|
|
85
|
+
.slice(0, limit)
|
|
86
|
+
.map(([repo]) => repo);
|
|
87
|
+
}
|
|
51
88
|
/**
|
|
52
89
|
* Load cache from disk
|
|
53
|
-
* @returns Cache data object
|
|
54
90
|
*/
|
|
55
91
|
loadCache() {
|
|
56
92
|
const cachePath = this.getFetchCachePath();
|
|
57
93
|
try {
|
|
58
94
|
if (!this.fs.existsSync(cachePath)) {
|
|
59
|
-
return {};
|
|
95
|
+
return { fetchTimestamps: {}, branchRepoUsage: [] };
|
|
60
96
|
}
|
|
61
97
|
const content = this.fs.readFileSync(cachePath, 'utf-8');
|
|
62
|
-
|
|
98
|
+
const parsed = JSON.parse(content);
|
|
99
|
+
return {
|
|
100
|
+
fetchTimestamps: parsed.fetchTimestamps ?? {},
|
|
101
|
+
branchRepoUsage: parsed.branchRepoUsage ?? [],
|
|
102
|
+
};
|
|
63
103
|
}
|
|
64
104
|
catch (error) {
|
|
65
105
|
// Corrupted cache - delete and start fresh
|
|
@@ -70,12 +110,11 @@ export class FetchCacheService {
|
|
|
70
110
|
catch {
|
|
71
111
|
// Ignore errors deleting corrupted cache
|
|
72
112
|
}
|
|
73
|
-
return {};
|
|
113
|
+
return { fetchTimestamps: {}, branchRepoUsage: [] };
|
|
74
114
|
}
|
|
75
115
|
}
|
|
76
116
|
/**
|
|
77
117
|
* Save cache to disk
|
|
78
|
-
* @param cache Cache data to save
|
|
79
118
|
*/
|
|
80
119
|
saveCache(cache) {
|
|
81
120
|
const cachePath = this.getFetchCachePath();
|
|
@@ -88,10 +127,9 @@ export class FetchCacheService {
|
|
|
88
127
|
}
|
|
89
128
|
/**
|
|
90
129
|
* Get path to fetch cache file
|
|
91
|
-
* @returns Absolute path to ~/.config/flow/fetch-cache.json
|
|
92
130
|
*/
|
|
93
131
|
getFetchCachePath() {
|
|
94
132
|
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
95
|
-
return path.join(home, '.config', 'flow', '
|
|
133
|
+
return path.join(home, '.config', 'flow', 'flow-cache.json');
|
|
96
134
|
}
|
|
97
135
|
}
|
|
@@ -29,10 +29,18 @@ export class WorkspaceConfigService {
|
|
|
29
29
|
const parsed = JSON.parse(raw);
|
|
30
30
|
return WorkspaceConfigSchema.parse(parsed);
|
|
31
31
|
}
|
|
32
|
+
savePlaceholder(workspacePath) {
|
|
33
|
+
const configPath = this.getConfigPath(workspacePath);
|
|
34
|
+
this.fs.writeFileSync(configPath, JSON.stringify({ baseBranches: {} }, null, 2) + '\n');
|
|
35
|
+
}
|
|
32
36
|
save(workspacePath, config) {
|
|
33
37
|
const configPath = this.getConfigPath(workspacePath);
|
|
34
38
|
const validated = WorkspaceConfigSchema.parse(config);
|
|
35
|
-
this.
|
|
39
|
+
const existing = this.load(workspacePath);
|
|
40
|
+
const merged = {
|
|
41
|
+
baseBranches: { ...existing.baseBranches, ...validated.baseBranches },
|
|
42
|
+
};
|
|
43
|
+
this.fs.writeFileSync(configPath, JSON.stringify(merged, null, 2) + '\n');
|
|
36
44
|
}
|
|
37
45
|
getBaseBranch(workspacePath, repoName) {
|
|
38
46
|
const config = this.load(workspacePath);
|
|
@@ -39,8 +39,16 @@ export class WorkspaceDirectoryService {
|
|
|
39
39
|
if (!this.fs.existsSync(workspacePath)) {
|
|
40
40
|
return null;
|
|
41
41
|
}
|
|
42
|
+
const configPath = path.join(workspacePath, 'flow-config.json');
|
|
43
|
+
if (!this.fs.existsSync(configPath)) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
42
46
|
return workspacePath;
|
|
43
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Returns the paths of git worktrees (repos) inside a workspace directory.
|
|
50
|
+
* @example getWorktreeDirs('/workspaces/feature-123') → ['/workspaces/feature-123/repo-a', ...]
|
|
51
|
+
*/
|
|
44
52
|
getWorktreeDirs(workspacePath) {
|
|
45
53
|
const entries = this.fs.readdirSync(workspacePath, { withFileTypes: true });
|
|
46
54
|
return entries
|
|
@@ -54,13 +62,22 @@ export class WorkspaceDirectoryService {
|
|
|
54
62
|
})
|
|
55
63
|
.map((entry) => path.join(workspacePath, entry.name));
|
|
56
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Returns all workspaces under destPath, identified by the presence of flow-config.json.
|
|
67
|
+
* @example listWorkspaces('/workspaces') → [{ name: 'feature-123', path: '...', repoCount: 2 }, ...]
|
|
68
|
+
*/
|
|
57
69
|
listWorkspaces(destPath) {
|
|
58
70
|
if (!this.fs.existsSync(destPath)) {
|
|
59
71
|
return [];
|
|
60
72
|
}
|
|
61
73
|
const entries = this.fs.readdirSync(destPath, { withFileTypes: true });
|
|
62
74
|
return entries
|
|
63
|
-
.filter((entry) =>
|
|
75
|
+
.filter((entry) => {
|
|
76
|
+
if (!entry.isDirectory())
|
|
77
|
+
return false;
|
|
78
|
+
const configPath = path.join(destPath, entry.name, 'flow-config.json');
|
|
79
|
+
return this.fs.existsSync(configPath);
|
|
80
|
+
})
|
|
64
81
|
.map((entry) => {
|
|
65
82
|
const workspacePath = path.join(destPath, entry.name);
|
|
66
83
|
try {
|
|
@@ -75,7 +92,7 @@ export class WorkspaceDirectoryService {
|
|
|
75
92
|
return null;
|
|
76
93
|
}
|
|
77
94
|
})
|
|
78
|
-
.filter((ws) => ws !== null
|
|
95
|
+
.filter((ws) => ws !== null);
|
|
79
96
|
}
|
|
80
97
|
removeWorkspaceDir(workspacePath) {
|
|
81
98
|
this.fs.rmSync(workspacePath, { recursive: true, force: true });
|
|
@@ -37,6 +37,7 @@ export class CheckoutWorkspaceUseCase {
|
|
|
37
37
|
}
|
|
38
38
|
// 3. Create workspace directory
|
|
39
39
|
const workspacePath = this.workspaceDir.createWorkspaceDir(params.destPath, params.branchName);
|
|
40
|
+
this.workspaceConfig.savePlaceholder(workspacePath);
|
|
40
41
|
// 4. Create worktrees in parallel
|
|
41
42
|
const successCount = await this.parallel.processInParallel(matching, (repoPath) => RepoService.getRepoName(repoPath), async (repoPath) => {
|
|
42
43
|
const name = RepoService.getRepoName(repoPath);
|
|
@@ -26,6 +26,7 @@ export class CreateBranchWorkspaceUseCase {
|
|
|
26
26
|
async execute(params) {
|
|
27
27
|
// 1. Create workspace directory
|
|
28
28
|
const workspacePath = this.workspaceDir.createWorkspaceDir(params.destPath, params.branchName);
|
|
29
|
+
this.workspaceConfig.savePlaceholder(workspacePath);
|
|
29
30
|
// Track base branches for each repo
|
|
30
31
|
const baseBranches = {};
|
|
31
32
|
// 2. Create worktrees in parallel
|