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
74 changes: 74 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,80 @@ Design principles:
- `wspace sync` — alias for `wspace init`.
- `wspace validate` — validate the manifest without touching any repository.

## Beginner Worktree Lifecycle

All workspace commands run from the workspace root (the directory containing
`repos.json`). When creating worktrees for parallel feature development, always
follow this standard lifecycle:

1. **Check workspace status**:
```sh
wspace check
```
2. **Refresh clean default branches**:
```sh
wspace update
```
3. **Create a feature worktree**:
```sh
git -C repos/<repo> worktree add "$PWD/worktrees/<repo>/<feature>" -b <feature>
```
_(Or using `wspace`: `wspace worktree add <repo> <feature>`)_
4. **Develop inside the worktree**:
```sh
cd worktrees/<repo>/<feature>
# make edits, run tests, commit changes
```
5. **Sync local secrets when needed**:
```sh
wspace env sync --dry-run # preview changes
wspace env sync # copy secrets with mode 0600 permissions
```
6. **Push and open a PR**:
```sh
git push -u origin <feature>
gh pr create
```
7. **Find stale worktrees after PR merge**:
```sh
wspace worktree list --stale
```
8. **Clean up merged worktree**:
```sh
wspace worktree remove <repo> <feature>
```

### Important Path Resolution Rules

- **Workspace Root Anchor**: All repository and worktree paths in `repos.json`
resolve relative to the directory containing `repos.json` (the manifest file),
regardless of the caller's current working directory.
- **Why `$PWD` is required with `git -C`**: `git -C repos/<repo>` changes Git's
working directory to `repos/<repo>` before executing. If you pass a relative
path like `worktrees/<repo>/<feature>`, Git creates the worktree nested inside
`repos/<repo>/worktrees/...` instead of at the workspace root. Using
`"$PWD/worktrees/<repo>/<feature>"` resolves `$PWD` from the workspace root
before Git runs.
- **Default Worktree Baseline**: `wspace worktree add` branches from
`origin/<default>` (resolved via `origin/HEAD`), ensuring feature branches
start from the remote baseline rather than a local dirty state or arbitrary
`HEAD`.

### Troubleshooting & Common Pitfalls

- **`PATH_BLOCKED` or `INVALID` during `init`**: An existing directory or file
occupies the expected repository path but is not a valid Git repository.
`wspace init` fails closed without touching or overwriting the path. Remove or
relocate the blocking path manually.
- **`SKIP_FEATURE` / `FEATURE_CLEAN`**: Indicates that `repos/<repo>` is checked
out on a feature branch instead of the default branch. `wspace update` skips
updating feature branches to protect user work.
- **Symlink rejection in `env sync`**: For security, `wspace env sync` will
refuse to overwrite any destination path that is a symbolic link.
- **Dirty linked worktrees**: `wspace check` inspects both primary checkouts and
linked feature worktrees. If any linked worktree has uncommitted changes,
`wspace check` returns a non-zero exit code (`1`).

## Install

Install from JSR as the `wspace` binary:
Expand Down
49 changes: 39 additions & 10 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ interface CliOptions {
manifestPath: string;
json: boolean;
stale: boolean;
dryRun: boolean;
positional: string[];
}

Expand All @@ -56,17 +57,25 @@ Usage:
wspace worktree add <repo> <feature> [<commit-ish>]
wspace worktree list [--stale] [--json]
wspace worktree remove <repo> <feature>
wspace env sync
wspace env sync [--dry-run] [--json]
wspace validate

Options:
--manifest <path> Manifest path (default: repos.json)
--json Machine-readable output`);
--json Machine-readable output
--stale Filter worktrees fully merged into origin/<default> (or missing branch)
--dry-run Preview environment sync operations without modifying files

Worktree Commands:
worktree add Creates a worktree at worktrees/<repo>/<feature> on branch <feature>.
Start-point defaults to origin/<default> (resolved via origin/HEAD).
worktree list Lists active worktrees. With --stale, lists safe removal candidates.
worktree remove Removes a worktree at worktrees/<repo>/<feature> and prunes stale references.`);
}

function parseCliArgs(args: string[]): CliOptions {
const parsed = parseArgs(args, {
boolean: ["help", "json", "stale"],
boolean: ["help", "json", "stale", "dry-run"],
string: ["manifest"],
alias: { h: "help" },
});
Expand All @@ -87,6 +96,7 @@ function parseCliArgs(args: string[]): CliOptions {
manifestPath: parsed.manifest ?? "repos.json",
json: parsed.json ?? false,
stale: parsed.stale ?? false,
dryRun: parsed["dry-run"] ?? false,
positional: positional.slice(2),
};
}
Expand Down Expand Up @@ -161,6 +171,10 @@ async function runWorktree(
return 2;
}
const worktreePath = join(paths.worktreesDirectory, repoName, feature);
if (await exists(worktreePath)) {
console.error(`Worktree path already exists: ${worktreePath}`);
return 2;
}
const reattach = await branchExists(g, repoPath, feature);
let startPointArg: string | undefined = startPoint;
if (reattach) {
Expand Down Expand Up @@ -234,15 +248,23 @@ async function cloneMissing(
for (const repository of manifest.repositories) {
const repoPath = resolveRepositoryPath(repository, paths);
if (await exists(repoPath)) {
rows.push({ name: repository.name, state: "EXISTS" });
if (await exists(join(repoPath, ".git"))) {
rows.push({ name: repository.name, state: "EXISTS" });
} else {
rows.push({
name: repository.name,
state: "PATH_BLOCKED",
detail: "Path exists but is not a Git repository",
});
}
continue;
}
const result = await clone(g, repository.url, repoPath);
rows.push(
result.code === 0 ? { name: repository.name, state: "CLONED" } : {
name: repository.name,
state: "CLONE_FAILED",
detail: result.stderr,
detail: result.stderr.trim() || `Exit code ${result.code}`,
},
);
}
Expand Down Expand Up @@ -273,7 +295,13 @@ async function runCommand(
} else {
console.table(rows);
}
if (opts.command === "init") {
const failed = rows.some(
(r) =>
r.state === "CLONE_FAILED" ||
r.state === "PATH_BLOCKED" ||
r.state === "INVALID",
);
if (opts.command === "init" && !failed) {
console.error(
`NOTE: Fresh clones do not contain files listed in .gitignore.
Required setup steps may include:
Expand All @@ -282,7 +310,7 @@ Required setup steps may include:
- Any repo-specific setup documented in each repo's README`,
);
}
return 0;
return failed ? 1 : 0;
}
case "update": {
const rows = await runUpdate(g, manifest, paths);
Expand All @@ -297,16 +325,17 @@ Required setup steps may include:
return await runWorktree(opts, manifest, paths, g);
case "env": {
if (opts.subcommand !== "sync") {
console.error("Usage: wspace env sync");
console.error("Usage: wspace env sync [--dry-run] [--json]");
return 2;
}
const rows = await syncEnv(g, manifest, paths);
const rows = await syncEnv(g, manifest, paths, { dryRun: opts.dryRun });
if (opts.json) {
console.log(JSON.stringify(rows, null, 2));
} else {
console.table(rows);
}
return 0;
const hasFailed = rows.some((r) => r.action === "FAILED");
return hasFailed ? 1 : 0;
}
case "validate": {
validateManifest(manifest);
Expand Down
88 changes: 82 additions & 6 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,29 @@ function matchPattern(pattern: string, name: string): boolean {
return false;
}

export interface SyncEnvOptions {
dryRun?: boolean;
}

export interface SyncEnvResult {
repo: string;
file: string;
destination: string;
action:
| "CREATED"
| "OVERWRITTEN"
| "WOULD_CREATE"
| "WOULD_OVERWRITE"
| "SKIPPED"
| "FAILED";
reason?: string;
}

export async function syncEnv(
g: GitRunner,
manifest: WorkspaceManifest,
paths: ManifestPaths,
options: SyncEnvOptions = {},
): Promise<SyncEnvResult[]> {
const synced: SyncEnvResult[] = [];
if (!(await exists(paths.vaultDirectory))) {
Expand Down Expand Up @@ -80,12 +93,75 @@ export async function syncEnv(
continue;
}
const destination = join(target, fileEntry.name);
await Deno.copyFile(source, destination);
synced.push({
repo: repository.name,
file: fileEntry.name,
destination,
});

let destExists = false;
try {
const lstat = await Deno.lstat(destination);
destExists = true;
if (lstat.isSymlink) {
synced.push({
repo: repository.name,
file: fileEntry.name,
destination,
action: "FAILED",
reason: "Destination is a symlink (rejected for security)",
});
continue;
}
} catch (err) {
if (!(err instanceof Deno.errors.NotFound)) {
synced.push({
repo: repository.name,
file: fileEntry.name,
destination,
action: "FAILED",
reason: err instanceof Error ? err.message : String(err),
});
continue;
}
}

if (options.dryRun) {
synced.push({
repo: repository.name,
file: fileEntry.name,
destination,
action: destExists ? "WOULD_OVERWRITE" : "WOULD_CREATE",
});
continue;
}

try {
const tempDest = `${destination}.tmp.${
Math.random().toString(36).slice(2)
}`;
await Deno.copyFile(source, tempDest);
try {
await Deno.chmod(tempDest, 0o600);
} catch {
// ignore chmod errors on unsupported OS / filesystems
}
await Deno.rename(tempDest, destination);
try {
await Deno.chmod(destination, 0o600);
} catch {
// ignore chmod errors
}
synced.push({
repo: repository.name,
file: fileEntry.name,
destination,
action: destExists ? "OVERWRITTEN" : "CREATED",
});
} catch (err) {
synced.push({
repo: repository.name,
file: fileEntry.name,
destination,
action: "FAILED",
reason: err instanceof Error ? err.message : String(err),
});
}
}
}
}
Expand Down
Loading
Loading