Skip to content
Open
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
19 changes: 19 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ packages:
- "effect-ts"
- "fs"
- "fx"
- "project-repo"
- "jsonl-store"
- "node"
- "process"
Expand Down
153 changes: 153 additions & 0 deletions project-repo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# @effectionx/project-repo

Project repository utilities for [Effection](https://frontside.com/effection) - manage worktrees, clones, and repository tags with structured concurrency.

## Installation

```bash
npm install @effectionx/project-repo
```

## Features

- **Worktrees**: Create and manage git worktrees for parallel version checkouts
- **Clones**: Clone and cache GitHub repositories
- **Repository**: Query git tags with semver sorting
- **Semver**: Extract and compare semantic versions from tag names

## Usage

### Worktrees

Manage git worktrees for checking out multiple refs simultaneously:

```typescript
import { main } from "effection";
import { initWorktrees, useWorktree } from "@effectionx/project-repo";

await main(function* () {
// Initialize the worktrees directory
// By default, uses the current working directory as the git repository
yield* initWorktrees("./build/worktrees");

// Or specify a different git repository
yield* initWorktrees("./build/worktrees", { cwd: "/path/to/repo" });

// Create worktrees for different versions
const v3Path = yield* useWorktree("v3.0.0");
const v4Path = yield* useWorktree("v4.0.0");

console.log(`v3 at: ${v3Path}`);
console.log(`v4 at: ${v4Path}`);
});
```

### Clones

Clone and cache GitHub repositories:

```typescript
import { main } from "effection";
import { initClones, useClone } from "@effectionx/project-repo";

await main(function* () {
// Initialize the clones directory
yield* initClones("./build/clones");

// Clone repositories (cached if already exists)
const effectionPath = yield* useClone("thefrontside/effection");
const effectionxPath = yield* useClone("thefrontside/effectionx");

console.log(`Effection at: ${effectionPath}`);
});
```

### Repository Tags

Query and sort git tags by semver:

```typescript
import { main } from "effection";
import { createRepo } from "@effectionx/project-repo";

await main(function* () {
// Create a repo abstraction (uses current working directory by default)
const repo = createRepo({
owner: "thefrontside",
name: "effection",
});

// Or specify a different git repository
const repo2 = createRepo({
owner: "thefrontside",
name: "effection",
cwd: "/path/to/repo",
});

// Get all v4.x tags
const v4Tags = yield* repo.tags(/^v4\./);
console.log("v4 tags:", v4Tags.map((t) => t.name));

// Get the latest v4.x tag
const latest = yield* repo.latest(/^v4\./);
console.log(`Latest: ${latest.name}`);
console.log(`URL: ${latest.url}`);
});
```

### Semver Utilities

Extract and compare versions from tag names:

```typescript
import { extractVersion, findLatestSemverTag } from "@effectionx/project-repo";

// Extract version from tag name
extractVersion("v3.2.1"); // "3.2.1"
extractVersion("release-1.0.0-beta.1"); // "1.0.0-beta.1"

// Find the latest tag from a list
const tags = [{ name: "v1.0.0" }, { name: "v2.0.0" }, { name: "v1.5.0" }];

const latest = findLatestSemverTag(tags);
console.log(latest?.name); // "v2.0.0"
```

## API

### Worktrees

- `initWorktrees(basePath, options?)` - Initialize the worktrees base directory
- `options.clean` - Clean the directory before initializing (default: `true`)
- `options.cwd` - The git repository directory to create worktrees from
- `useWorktree(refname)` - Get or create a worktree for a git ref

### Clones

- `initClones(basePath, options?)` - Initialize the clones base directory
- `options.clean` - Clean the directory before initializing (default: `true`)
- `useClone(nameWithOwner)` - Clone or use cached GitHub repository

### Repository

- `createRepo(options)` - Create a repository abstraction
- `options.owner` - Repository owner (organization or user)
- `options.name` - Repository name
- `options.cwd` - The git repository directory to run commands in
- `repo.tags(pattern)` - Get tags matching a regex pattern
- `repo.latest(pattern)` - Get the latest semver tag matching a pattern

### Semver

- `extractVersion(input)` - Extract semver from a string
- `findLatestSemverTag(tags)` - Find the latest tag by semver

## Requirements

- Node.js >= 22
- Effection ^3 || ^4
- Git must be installed and available in PATH

## License

MIT
54 changes: 54 additions & 0 deletions project-repo/clones.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { beforeEach, describe, it } from "@effectionx/bdd";
import {
emptyDir,
ensureDir,
exists,
readdir,
writeTextFile,
} from "@effectionx/fs";
import { expect } from "expect";

import { initClones } from "./mod.ts";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const testDir = path.join(__dirname, "test-tmp", "clones-test");

describe("clones", () => {
beforeEach(function* () {
yield* emptyDir(testDir);
});

describe("initClones", () => {
it("creates the clones directory", function* () {
const clonesDir = path.join(testDir, "clones");

yield* initClones(clonesDir);

expect(yield* exists(clonesDir)).toBe(true);
});

it("cleans the directory by default", function* () {
const clonesDir = path.join(testDir, "clones");
yield* ensureDir(clonesDir);
yield* writeTextFile(path.join(clonesDir, "old.txt"), "old");

yield* initClones(clonesDir);

const contents = yield* readdir(clonesDir);
expect(contents).toHaveLength(0);
});

it("preserves contents when clean is false", function* () {
const clonesDir = path.join(testDir, "clones");
yield* ensureDir(clonesDir);
yield* writeTextFile(path.join(clonesDir, "keep.txt"), "keep");

yield* initClones(clonesDir, { clean: false });

const contents = yield* readdir(clonesDir);
expect(contents).toContain("keep.txt");
});
});
});
73 changes: 73 additions & 0 deletions project-repo/clones.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import * as path from "node:path";
import { emptyDir, ensureDir, exists } from "@effectionx/fs";
import { exec } from "@effectionx/process";
import { type Operation, createContext } from "effection";

const ClonesContext = createContext<string>("git.clones");

/**
* Options for initializing clones
*/
export interface CloneOptions {
/** Clean the directory before initializing (default: true) */
clean?: boolean;
}

/**
* Initialize the clones base directory and set the context.
* This should be called once at the start of your application
* before using `useClone`.
*
* @example
* ```ts
* import { initClones, useClone } from "@effectionx/project-repo";
*
* yield* initClones("./build/clones");
* const effectionPath = yield* useClone("thefrontside/effection");
* ```
*/
export function* initClones(
basePath: string,
options: CloneOptions = {},
): Operation<void> {
const { clean = true } = options;

if (clean) {
yield* emptyDir(basePath);
} else {
yield* ensureDir(basePath);
}

yield* ClonesContext.set(basePath);
}

/**
* Clone a GitHub repository if it doesn't already exist.
* The repository will be cloned to the base directory set by `initClones`.
*
* @param nameWithOwner - The repository in "owner/repo" format (e.g., "thefrontside/effection")
* @returns The path to the cloned repository
*
* @example
* ```ts
* import { initClones, useClone } from "@effectionx/project-repo";
*
* yield* initClones("./build/clones");
*
* // Clone from GitHub
* const effectionPath = yield* useClone("thefrontside/effection");
* console.log(effectionPath); // "./build/clones/thefrontside/effection"
* ```
*/
export function* useClone(nameWithOwner: string): Operation<string> {
const basePath = yield* ClonesContext.expect();
const dirpath = path.resolve(basePath, nameWithOwner);

if (!(yield* exists(dirpath))) {
yield* exec(
`git clone https://github.com/${nameWithOwner} "${dirpath}"`,
).expect();
}

return dirpath;
}
13 changes: 13 additions & 0 deletions project-repo/mod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export {
initWorktrees,
useWorktree,
type WorktreeOptions,
} from "./worktrees.ts";
export { initClones, useClone, type CloneOptions } from "./clones.ts";
export {
createRepo,
type Repo,
type Ref,
type RepoOptions,
} from "./repo.ts";
export { extractVersion, findLatestSemverTag } from "./semver.ts";
37 changes: 37 additions & 0 deletions project-repo/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "@effectionx/project-repo",
"version": "0.1.0",
"type": "module",
"main": "./dist/mod.js",
"types": "./dist/mod.d.ts",
"exports": {
".": {
"development": "./mod.ts",
"default": "./dist/mod.js"
}
},
"peerDependencies": {
"effection": "^3 || ^4"
},
"dependencies": {
"semver": "^7.6.3",
"@effectionx/fs": "workspace:*",
"@effectionx/process": "workspace:*"
},
"devDependencies": {
"@effectionx/bdd": "workspace:*"
},
"license": "MIT",
"author": "engineering@frontside.com",
"repository": {
"type": "git",
"url": "git+https://github.com/thefrontside/effectionx.git"
},
"bugs": {
"url": "https://github.com/thefrontside/effectionx/issues"
},
"engines": {
"node": ">= 22"
},
"sideEffects": false
}
Loading
Loading