Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quick-houses-guess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fuzdev/fuz_gitops': minor
---

add `gitops_run` task, tweak some interfaces
20 changes: 17 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,11 @@ gro gitops_sync # sync repos and update local data
gro gitops_sync --download # clone missing repos
gro gitops_sync --check # verify repos are ready without fetching data

# Run commands across repos
gro gitops_run "npm test" # run command in all repos (parallel, concurrency: 5)
gro gitops_run "npm audit" --concurrency 3 # limit parallelism
gro gitops_run "gro check" --format json # JSON output for scripting

# Publishing
gro gitops_validate # validate configuration (runs analyze, plan, and dry run)
gro gitops_analyze # analyze dependencies and changesets
Expand Down Expand Up @@ -355,6 +360,15 @@ gro test src/test/fixtures/check # validate gitops commands against fixture
- Switches branches and pulls latest changes
- Installs dependencies if package.json changed
- Verify repos ready without fetching (with `--check`)
- Runs in parallel (concurrency: 5 by default)

**Command Execution (User-Defined Side Effects):**

- `gro gitops_run "<command>"` - Run shell command across all repos
- Parallel execution (concurrency: 5 by default)
- Continue-on-error behavior
- Structured output (text or JSON)
- Use for testing, auditing, batch operations

**Publishing (Git & NPM Side Effects):**

Expand Down Expand Up @@ -393,7 +407,7 @@ For packages you control, use `>=` instead of `^` for peer dependencies:

```json
"peerDependencies": {
"@fuzdev/fuz_util": ">=0.38.0", // controlled package - use >=
"@fuzdev/fuz_util": ">=0.38.0", // controlled package - use >=
"@ryanatkn/gro": ">=0.174.0", // controlled package - use >=
"@sveltejs/kit": "^2", // third-party - use ^
"svelte": "^5" // third-party - use ^
Expand Down Expand Up @@ -464,8 +478,8 @@ Uses vitest with **zero mocks** - all tests use the operations pattern for
dependency injection (see above).

```bash
gro test # run all tests
gro test version_utils # run specific test file
gro test # run all tests
gro test version_utils # run specific test file
gro test src/test/fixtures/check # validate command output fixtures
```

Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,21 @@ See [CLAUDE.md](CLAUDE.md#architecture) for detailed documentation.

## Quick Start

### Running commands across repos

```bash
gro gitops_run "npm test" # run tests in all repos (parallel, concurrency: 5)
gro gitops_run "npm audit" --concurrency 3 # limit parallelism
gro gitops_run "git status" --format json # JSON output for scripting
```

**Features:**

- Parallel execution with configurable concurrency (default: 5)
- Continue-on-error behavior (shows all results)
- Structured output formats (text or JSON)
- Uses lightweight repo path resolution (no full sync needed)

### Syncing repo metadata

```bash
Expand Down
8 changes: 0 additions & 8 deletions src/lib/constants.ts

This file was deleted.

11 changes: 6 additions & 5 deletions src/lib/gitops_analyze.task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,17 @@ import {
format_production_cycles,
} from './log_helpers.js';
import {format_and_output, type OutputFormatters} from './output_helpers.js';
import {GITOPS_CONFIG_PATH_DEFAULT} from './gitops_constants.js';

/** @nodocs */
export const Args = z.strictObject({
path: z
config: z
.string()
.meta({description: 'path to the gitops config file, absolute or relative to the cwd'})
.default('gitops.config.ts'),
.default(GITOPS_CONFIG_PATH_DEFAULT),
dir: z
.string()
.meta({description: 'path containing the repos, defaults to the parent of the `path` dir'})
.meta({description: 'path containing the repos, defaults to the parent of the config dir'})
.optional(),
format: z
.enum(['stdout', 'json', 'markdown'])
Expand All @@ -37,10 +38,10 @@ export const task: Task<Args> = {
Args,
summary: 'analyze dependency structure and relationships across repos',
run: async ({args, log}) => {
const {path, dir, format, outfile} = args;
const {config, dir, format, outfile} = args;

// Get repos ready (without downloading)
const {local_repos} = await get_gitops_ready({path, dir, download: false, log});
const {local_repos} = await get_gitops_ready({config, dir, download: false, log});

// Build dependency graph and validate (but don't throw on cycles for analyze)
const {graph, publishing_order: order} = validate_dependency_graph(local_repos, {
Expand Down
30 changes: 30 additions & 0 deletions src/lib/gitops_constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Shared constants for gitops tasks and operations.
*
* Naming convention: GITOPS_{NAME}_DEFAULT for user-facing defaults.
*/

/**
* Maximum number of iterations for fixed-point iteration during publishing.
* Used in both plan generation and actual publishing to resolve transitive dependency cascades.
*
* In practice, most repos converge in 2-3 iterations.
* Deep dependency chains may require more iterations.
*/
export const GITOPS_MAX_ITERATIONS_DEFAULT = 10;

/**
* Default path to the gitops configuration file.
*/
export const GITOPS_CONFIG_PATH_DEFAULT = 'gitops.config.ts';

/**
* Default number of repos to process concurrently during parallel operations.
*/
export const GITOPS_CONCURRENCY_DEFAULT = 5;

/**
* Default timeout in milliseconds for waiting on NPM package propagation (10 minutes).
* NPM's CDN uses eventual consistency, so published packages may not be immediately available.
*/
export const GITOPS_NPM_WAIT_TIMEOUT_DEFAULT = 600_000; // 10 minutes
13 changes: 7 additions & 6 deletions src/lib/gitops_plan.task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,17 @@ import {
type LogPlanOptions,
} from './publishing_plan.js';
import {format_and_output, type OutputFormatters} from './output_helpers.js';
import {GITOPS_CONFIG_PATH_DEFAULT} from './gitops_constants.js';

/** @nodocs */
export const Args = z.strictObject({
path: z
config: z
.string()
.meta({description: 'path to the gitops config file, absolute or relative to the cwd'})
.default('gitops.config.ts'),
.default(GITOPS_CONFIG_PATH_DEFAULT),
dir: z
.string()
.meta({description: 'path containing the repos, defaults to the parent of the `path` dir'})
.meta({description: 'path containing the repos, defaults to the parent of the config dir'})
.optional(),
format: z
.enum(['stdout', 'json', 'markdown'])
Expand All @@ -37,21 +38,21 @@ export type Args = z.infer<typeof Args>;
* Usage:
* gro gitops_plan
* gro gitops_plan --dir ../repos
* gro gitops_plan --path ./custom.config.ts
* gro gitops_plan --config ./custom.config.ts
*
* @nodocs
*/
export const task: Task<Args> = {
summary: 'generate a publishing plan based on changesets',
Args,
run: async ({args, log}): Promise<void> => {
const {dir, path, format, outfile, verbose} = args;
const {dir, config, format, outfile, verbose} = args;

log.info(st('cyan', 'Generating multi-repo publishing plan...'));

// Load local repos
const {local_repos} = await get_gitops_ready({
path,
config,
dir,
download: false, // Don't download if missing
log,
Expand Down
13 changes: 7 additions & 6 deletions src/lib/gitops_publish.task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,17 @@ import {
} from './multi_repo_publisher.js';
import {generate_publishing_plan, log_publishing_plan} from './publishing_plan.js';
import {format_and_output, type OutputFormatters} from './output_helpers.js';
import {GITOPS_CONFIG_PATH_DEFAULT, GITOPS_NPM_WAIT_TIMEOUT_DEFAULT} from './gitops_constants.js';

/** @nodocs */
export const Args = z.strictObject({
path: z
config: z
.string()
.meta({description: 'path to the gitops config file, absolute or relative to the cwd'})
.default('gitops.config.ts'),
.default(GITOPS_CONFIG_PATH_DEFAULT),
dir: z
.string()
.meta({description: 'path containing the repos, defaults to the parent of the `path` dir'})
.meta({description: 'path containing the repos, defaults to the parent of the config dir'})
.optional(),
peer_strategy: z
.enum(['exact', 'caret', 'tilde'])
Expand All @@ -43,7 +44,7 @@ export const Args = z.strictObject({
max_wait: z
.number()
.meta({description: 'max time to wait for npm propagation in ms'})
.default(600000), // 10 minutes
.default(GITOPS_NPM_WAIT_TIMEOUT_DEFAULT),
skip_install: z
.boolean()
.meta({description: 'skip npm install after dependency updates'})
Expand All @@ -59,7 +60,7 @@ export const task: Task<Args> = {
Args,
run: async ({args, log}): Promise<void> => {
const {
path,
config,
dir,
peer_strategy,
dry_run,
Expand All @@ -74,7 +75,7 @@ export const task: Task<Args> = {

// Load repos
const {local_repos: repos} = await get_gitops_ready({
path,
config,
dir,
download: false, // Don't download if missing
log,
Expand Down
Loading