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
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export enum GitPrErrorCode {
GIT_ERROR = 'GIT_ERROR',
MERGE_FAILED = 'MERGE_FAILED',
PR_NOT_FOUND = 'PR_NOT_FOUND',
REMOTE_ALREADY_EXISTS = 'REMOTE_ALREADY_EXISTS',
REPO_CREATE_FAILED = 'REPO_CREATE_FAILED',
}

/**
Expand Down Expand Up @@ -162,6 +164,37 @@ export interface IGitPrService {
*/
getRemoteUrl(cwd: string): Promise<string | null>;

/**
* Create a GitHub repository and link it to the local repo.
* Uses `gh repo create` with `--source=.` and `--push` to atomically
* create the remote repo, add the origin remote, and push the current branch.
*
* @param cwd - Working directory path
* @param name - Repository name (e.g. "my-repo" or "org/my-repo")
* @param options - Creation options
* @param options.isPrivate - Whether to create a private repository
* @param options.org - Optional GitHub organization to create the repo under
* @returns The URL of the created GitHub repository
* @throws GitPrError with REPO_CREATE_FAILED code on creation failure
* @throws GitPrError with GH_NOT_FOUND code if gh CLI is not installed
* @throws GitPrError with AUTH_FAILURE code if gh is not authenticated
*/
createGitHubRepo(
cwd: string,
name: string,
options: { isPrivate: boolean; org?: string }
): Promise<string>;

/**
* Add a git remote to the local repository.
*
* @param cwd - Working directory path
* @param remoteName - Name for the remote (e.g. "origin")
* @param remoteUrl - URL of the remote repository
* @throws GitPrError with GIT_ERROR code on failure
*/
addRemote(cwd: string, remoteName: string, remoteUrl: string): Promise<void>;

/**
* Detect the repository's default branch with robust fallback chain:
* 1. Remote HEAD (git symbolic-ref refs/remotes/origin/HEAD)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Init Remote Repository Use Case
*
* Orchestrates creating a GitHub repository and linking it to a local repo
* that has no remote configured. Validates gh CLI availability, guards against
* existing remotes, and delegates to IGitPrService.createGitHubRepo() for the
* atomic create + remote + push operation.
*/

import { injectable, inject } from 'tsyringe';
import { basename } from 'node:path';
import type { IGitPrService } from '../../ports/output/services/git-pr-service.interface.js';
import {
GitPrError,
GitPrErrorCode,
} from '../../ports/output/services/git-pr-service.interface.js';
import type { IToolInstallerService } from '../../ports/output/services/tool-installer.service.js';

export interface InitRemoteInput {
cwd: string;
name?: string;
isPublic?: boolean;
org?: string;
}

export interface InitRemoteResult {
repoUrl: string;
repoName: string;
isPrivate: boolean;
}

@injectable()
export class InitRemoteRepositoryUseCase {
constructor(
@inject('IGitPrService')
private readonly gitPrService: IGitPrService,
@inject('IToolInstallerService')
private readonly toolInstaller: IToolInstallerService
) {}

async execute(input: InitRemoteInput): Promise<InitRemoteResult> {
// 1. Check gh CLI availability
const ghStatus = await this.toolInstaller.checkAvailability('gh');
if (ghStatus.status !== 'available') {
throw new GitPrError(
'gh CLI is not installed. Install it with: brew install gh (macOS) or see https://cli.github.com/',
GitPrErrorCode.GH_NOT_FOUND
);
}

// 2. Guard against existing remote
const hasRemote = await this.gitPrService.hasRemote(input.cwd);
if (hasRemote) {
throw new GitPrError(
'A remote is already configured for this repository. Use `git remote -v` to view existing remotes.',
GitPrErrorCode.REMOTE_ALREADY_EXISTS
);
}

// 3. Derive repo name from cwd basename if not provided
const repoName = input.name ?? basename(input.cwd.replace(/\\/g, '/'));
const isPrivate = !input.isPublic;

// 4. Create GitHub repo (atomic: creates repo + adds remote + pushes)
const repoUrl = await this.gitPrService.createGitHubRepo(input.cwd, repoName, {
isPrivate,
org: input.org,
});

return {
repoUrl,
repoName,
isPrivate,
};
}
}
5 changes: 5 additions & 0 deletions packages/core/src/infrastructure/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ import { ListRepositoriesUseCase } from '../../application/use-cases/repositorie
import { DeleteRepositoryUseCase } from '../../application/use-cases/repositories/delete-repository.use-case.js';
import { ImportGitHubRepositoryUseCase } from '../../application/use-cases/repositories/import-github-repository.use-case.js';
import { ListGitHubRepositoriesUseCase } from '../../application/use-cases/repositories/list-github-repositories.use-case.js';
import { InitRemoteRepositoryUseCase } from '../../application/use-cases/repositories/init-remote-repository.use-case.js';
import { CheckAndUnblockFeaturesUseCase } from '../../application/use-cases/features/check-and-unblock-features.use-case.js';
import { UpdateFeatureLifecycleUseCase } from '../../application/use-cases/features/update/update-feature-lifecycle.use-case.js';
import { CleanupFeatureWorktreeUseCase } from '../../application/use-cases/features/cleanup-feature-worktree.use-case.js';
Expand Down Expand Up @@ -368,6 +369,7 @@ export async function initializeContainer(): Promise<typeof container> {
container.registerSingleton(DeleteRepositoryUseCase);
container.registerSingleton(ImportGitHubRepositoryUseCase);
container.registerSingleton(ListGitHubRepositoriesUseCase);
container.registerSingleton(InitRemoteRepositoryUseCase);
// CheckAndUnblockFeaturesUseCase must be registered before UpdateFeatureLifecycleUseCase
// because the latter injects the former via class token.
container.registerSingleton(CheckAndUnblockFeaturesUseCase);
Expand Down Expand Up @@ -455,6 +457,9 @@ export async function initializeContainer(): Promise<typeof container> {
container.register('ListGitHubRepositoriesUseCase', {
useFactory: (c) => c.resolve(ListGitHubRepositoriesUseCase),
});
container.register('InitRemoteRepositoryUseCase', {
useFactory: (c) => c.resolve(InitRemoteRepositoryUseCase),
});
container.register('CheckAndUnblockFeaturesUseCase', {
useFactory: (c) => c.resolve(CheckAndUnblockFeaturesUseCase),
});
Expand Down
37 changes: 37 additions & 0 deletions packages/core/src/infrastructure/services/git/git-pr.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,43 @@ export class GitPrService implements IGitPrService {
return match ? parseInt(match[1], 10) : 0;
}

async createGitHubRepo(
cwd: string,
name: string,
options: { isPrivate: boolean; org?: string }
): Promise<string> {
const repoName = options.org ? `${options.org}/${name}` : name;
const visibilityFlag = options.isPrivate ? '--private' : '--public';
const args = [
'repo',
'create',
repoName,
visibilityFlag,
'--source=.',
'--remote=origin',
'--push',
];

try {
const { stdout } = await this.execFile('gh', args, { cwd });
return stdout.trim();
} catch (error) {
const ghError = this.parseGhError(error);
if (ghError.code === GitPrErrorCode.GIT_ERROR) {
throw new GitPrError(ghError.message, GitPrErrorCode.REPO_CREATE_FAILED, ghError.cause);
}
throw ghError;
}
}

async addRemote(cwd: string, remoteName: string, remoteUrl: string): Promise<void> {
try {
await this.execFile('git', ['remote', 'add', remoteName, remoteUrl], { cwd });
} catch (error) {
throw this.parseGitError(error);
}
}

private parseDiffStat(diffStat: string, logOutput: string): DiffSummary {
const summaryLine = diffStat.trim().split('\n').pop() ?? '';
const filesMatch = summaryLine.match(/(\d+)\s+files?\s+changed/);
Expand Down
19 changes: 19 additions & 0 deletions specs/069-github-remote-init/evidence/build-typecheck-results.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
TypeScript Build — Compilation Results
=======================================

$ pnpm build

> @shepai/[email protected] build
> pnpm build:cli

> @shepai/[email protected] build:cli
> tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json --resolve-full-paths && ...

Build completed successfully — zero TypeScript errors.

Evidence: The full TypeScript compilation succeeds with no errors, confirming:
- GitPrErrorCode enum extensions (REMOTE_ALREADY_EXISTS, REPO_CREATE_FAILED) are valid
- IGitPrService interface extensions (createGitHubRepo, addRemote) are properly typed
- GitPrService implementation satisfies the extended interface
- InitRemoteRepositoryUseCase types are correct
- CLI command imports and DI registration compile cleanly
40 changes: 40 additions & 0 deletions specs/069-github-remote-init/evidence/cli-help-output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
shep repo init-remote — CLI Help Output
========================================

$ shep repo init-remote --help

Usage: shep repo init-remote [options] [name]

Create a GitHub repository and configure the local git remote

Arguments:
name GitHub repository name (defaults to directory name)

Options:
--public Create a public repository (default: private) (default: false)
--org <name> Create the repository under a GitHub organization
-h, --help display help for command

---

$ shep repo --help

Usage: shep repo [options] [command]

Manage tracked repositories

Options:
-h, --help display help for command

Commands:
ls List tracked repositories
show <id> Display details of a tracked repository
init-remote [options] [name] Create a GitHub repository and configure the
local git remote
help [command] display help for command

Evidence: The init-remote command is properly registered under `shep repo`:
- Optional [name] argument for repo name (defaults to directory name)
- --public flag for public repos (defaults to private)
- --org <name> option for organization repos
- Listed alongside existing `ls` and `show` subcommands
31 changes: 31 additions & 0 deletions specs/069-github-remote-init/evidence/full-test-suite-results.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
Full Test Suite — Verification Results
======================================

Lint Check:
$ pnpm lint
Result: Clean — 0 errors, 0 warnings

Unit Tests:
$ pnpm test:unit
Test Files 316 passed (316)
Tests 4087 passed (4087)

Integration Tests:
$ pnpm test:int
Test Files 42 passed (42)
Tests 501 passed (501)

Build:
$ pnpm build
Result: TypeScript compilation successful — 0 errors

Feature-Specific Test Summary:
- git-pr.service.addRemote.test.ts: 3 tests passed
- git-pr.service.createGitHubRepo.test.ts: 7 tests passed
- init-remote-repository.use-case.test.ts: 12 tests passed
- init-remote.command.test.ts: 17 tests passed
Feature Total: 39 tests passed

Evidence: The entire codebase (4087 unit + 501 integration = 4588 tests)
passes with zero failures after the github-remote-init feature implementation.
No regressions introduced.
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
shep repo init-remote CLI Command — Integration Test Results
============================================================

Test File: tests/integration/cli/commands/repo/init-remote.command.test.ts
Runner: Vitest v4.0.18

✓ shep repo init-remote command > should be registered as a subcommand of repo (3ms)
✓ shep repo init-remote command > should create a command named "init-remote" (1ms)
✓ shep repo init-remote command > should have a description mentioning GitHub (0ms)
✓ shep repo init-remote command > should have an optional [name] argument (1ms)
✓ shep repo init-remote command > should call execute with cwd and default options when no args given (3ms)
✓ shep repo init-remote command > should pass [name] argument to use case (0ms)
✓ shep repo init-remote command > should pass --public flag as isPublic: true (1ms)
✓ shep repo init-remote command > should pass --org flag value to use case (0ms)
✓ shep repo init-remote command > should pass [name], --public, and --org together (1ms)
✓ shep repo init-remote command > should display success message with repo URL on success (0ms)
✓ shep repo init-remote command > should display visibility as "private" for private repos (0ms)
✓ shep repo init-remote command > should display visibility as "public" for public repos (0ms)
✓ shep repo init-remote command > should set process.exitCode = 1 on REMOTE_ALREADY_EXISTS error (1ms)
✓ shep repo init-remote command > should set process.exitCode = 1 on GH_NOT_FOUND error (0ms)
✓ shep repo init-remote command > should set process.exitCode = 1 on AUTH_FAILURE error (0ms)
✓ shep repo init-remote command > should set process.exitCode = 1 on REPO_CREATE_FAILED error and show gh message (0ms)
✓ shep repo init-remote command > should set process.exitCode = 1 on generic error (3ms)

Test Files 1 passed (1)
Tests 17 passed (17)

Evidence: All 17 integration tests pass. The CLI command correctly:
- Is registered under the `repo` command group
- Accepts optional [name] argument, --public flag, --org option
- Passes all arguments/options correctly to the use case
- Displays success message with repo URL and visibility
- Handles all error codes with actionable messages and process.exitCode = 1
- REMOTE_ALREADY_EXISTS: warns remote already configured
- GH_NOT_FOUND: shows install instructions
- AUTH_FAILURE: shows `gh auth login` guidance
- REPO_CREATE_FAILED: preserves gh error message
- Generic error: shows unexpected error message
16 changes: 16 additions & 0 deletions specs/069-github-remote-init/evidence/unit-test-addRemote.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
GitPrService.addRemote — Unit Test Results
==========================================

Test File: tests/unit/infrastructure/services/git/git-pr.service.addRemote.test.ts
Runner: Vitest v4.0.18

✓ GitPrService.addRemote > should call git remote add with correct arguments (2ms)
✓ GitPrService.addRemote > should resolve without returning a value on success (0ms)
✓ GitPrService.addRemote > should throw GitPrError with GIT_ERROR code when git remote add fails (1ms)

Test Files 1 passed (1)
Tests 3 passed (3)

Evidence: All 3 tests pass. addRemote() correctly invokes `git remote add`
with the provided remote name and URL, and wraps failures in GitPrError
with GIT_ERROR code.
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
GitPrService.createGitHubRepo — Unit Test Results
==================================================

Test File: tests/unit/infrastructure/services/git/git-pr.service.createGitHubRepo.test.ts
Runner: Vitest v4.0.18

✓ GitPrService.createGitHubRepo > should create a private repo with correct gh args (2ms)
✓ GitPrService.createGitHubRepo > should create a public repo with --public flag (1ms)
✓ GitPrService.createGitHubRepo > should prefix repo name with org when org option is provided (0ms)
✓ GitPrService.createGitHubRepo > should return the trimmed stdout as the repo URL (0ms)
✓ GitPrService.createGitHubRepo > should throw GitPrError with GH_NOT_FOUND when gh is not installed (ENOENT) (1ms)
✓ GitPrService.createGitHubRepo > should throw GitPrError with AUTH_FAILURE when gh is not authenticated (0ms)
✓ GitPrService.createGitHubRepo > should throw GitPrError with REPO_CREATE_FAILED on generic gh failure (0ms)

Test Files 1 passed (1)
Tests 7 passed (7)

Evidence: All 7 tests pass. createGitHubRepo() correctly:
- Invokes `gh repo create` with --private/--public flags and --source=. --push
- Prefixes org name when org option provided
- Returns trimmed stdout (repo URL)
- Maps ENOENT → GH_NOT_FOUND, auth errors → AUTH_FAILURE, generic → REPO_CREATE_FAILED
30 changes: 30 additions & 0 deletions specs/069-github-remote-init/evidence/unit-test-use-case.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
InitRemoteRepositoryUseCase — Unit Test Results
================================================

Test File: tests/unit/application/use-cases/repositories/init-remote-repository.use-case.test.ts
Runner: Vitest v4.0.18

✓ InitRemoteRepositoryUseCase > should create a GitHub repo, returning url, name, and privacy (3ms)
✓ InitRemoteRepositoryUseCase > should throw when gh CLI is not installed (1ms)
✓ InitRemoteRepositoryUseCase > should throw REMOTE_ALREADY_EXISTS when remote is already configured (1ms)
✓ InitRemoteRepositoryUseCase > should propagate errors from createGitHubRepo (1ms)
✓ InitRemoteRepositoryUseCase > should derive repo name from basename of cwd when name is not provided (0ms)
✓ InitRemoteRepositoryUseCase > should use custom name when provided (0ms)
✓ InitRemoteRepositoryUseCase > should pass org option through to createGitHubRepo (0ms)
✓ InitRemoteRepositoryUseCase > should pass isPrivate=false when isPublic is true (1ms)
✓ InitRemoteRepositoryUseCase > should default to private when isPublic is not specified (1ms)
✓ InitRemoteRepositoryUseCase > should handle Windows-style paths for basename extraction (0ms)
✓ InitRemoteRepositoryUseCase > should include actionable message when gh CLI is missing (1ms)
✓ InitRemoteRepositoryUseCase > should include actionable message when remote already exists (0ms)

Test Files 1 passed (1)
Tests 12 passed (12)

Evidence: All 12 tests pass. The use case correctly:
- Orchestrates: check gh availability → check hasRemote → createGitHubRepo → return result
- Guards: throws on missing gh CLI, throws REMOTE_ALREADY_EXISTS when remote exists
- Propagates: passes through errors from createGitHubRepo unchanged
- Derives: repo name from directory basename (cross-platform, handles Windows paths)
- Passes: custom name, org, isPublic options through to service layer
- Defaults: private when isPublic not specified
- Errors: includes actionable messages (install instructions, remote already exists guidance)
Loading
Loading