From 36f83d5c37d3638a1fd9bfa16cfcf0e51807d432 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Wed, 11 Feb 2026 11:07:03 -0500 Subject: [PATCH 1/5] ENG-219: Add clone command Co-Authored-By: Claude Opus 4.6 --- src/cli.ts | 3 +++ src/commands/clone.ts | 51 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 src/commands/clone.ts diff --git a/src/cli.ts b/src/cli.ts index 50ff300..c3e6988 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,10 @@ import { createApp } from './app.js'; +import { registerCloneCommand } from './commands/clone.js'; const program = createApp(); +registerCloneCommand(program); + program.parseAsync(process.argv).catch((err) => { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); diff --git a/src/commands/clone.ts b/src/commands/clone.ts new file mode 100644 index 0000000..996392a --- /dev/null +++ b/src/commands/clone.ts @@ -0,0 +1,51 @@ +import type { Command } from 'commander'; +import fs from 'fs-extra'; +import { simpleGit } from 'simple-git'; +import { getConfig } from '../config.js'; +import * as output from '../utils/output.js'; + +/** + * Registers the `clone` command with the CLI program. + * + * @since TBD + * + * @param {Command} program - The Commander.js program instance. + * + * @returns {void} + */ +export function registerCloneCommand(program: Command): void { + program + .command('clone') + .description('Clone a git repository.') + .option('--branch ', 'The branch to clone.') + .action(async (options: { branch?: string }) => { + const config = getConfig(); + const repo = config.getRepo(); + const buildDir = config.getBuildDir(); + + output.section('Cloning repository...'); + + // Remove existing build dir + if (await fs.pathExists(buildDir)) { + await fs.remove(buildDir); + } + + const git = simpleGit(); + const cloneOptions = [ + '--quiet', + '--recurse-submodules', + '-j8', + '--shallow-submodules', + '--depth', + '1', + ]; + + if (options.branch) { + cloneOptions.push('--branch', options.branch); + } + + await git.clone(repo, buildDir, cloneOptions); + + output.success(`Cloned ${repo} to ${buildDir}`); + }); +} From becb6dd258272f325461f451f1696583ff59411f Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Wed, 11 Feb 2026 11:43:37 -0500 Subject: [PATCH 2/5] ENG-219: Add clone command tests Co-Authored-By: Claude Opus 4.6 --- tests/commands/clone.test.ts | 101 +++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 tests/commands/clone.test.ts diff --git a/tests/commands/clone.test.ts b/tests/commands/clone.test.ts new file mode 100644 index 0000000..d8ac0f0 --- /dev/null +++ b/tests/commands/clone.test.ts @@ -0,0 +1,101 @@ +import path from 'node:path'; +import fs from 'fs-extra'; +import { + runPup, + resetFixtures, + writePuprc, + getPuprc, + fakeProjectDir, + pupRoot, +} from '../helpers/setup.js'; + +describe('clone command', () => { + afterEach(() => { + resetFixtures(); + // Clean up cloned build directory + const buildDir = path.join(fakeProjectDir, '.pup-build'); + if (fs.existsSync(buildDir)) { + fs.removeSync(buildDir); + } + }); + + it('should clone a repository using file:// protocol', async () => { + const puprc = getPuprc({ repo: `file://${pupRoot}` }); + writePuprc(puprc); + + const result = await runPup('clone'); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Cloning repository...'); + expect(result.output).toContain('Cloned'); + + const buildDir = path.join(fakeProjectDir, '.pup-build'); + expect(fs.existsSync(buildDir)).toBe(true); + }); + + it('should clone a specific branch', async () => { + const puprc = getPuprc({ repo: `file://${pupRoot}` }); + writePuprc(puprc); + + const result = await runPup('clone --branch main'); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Cloned'); + }); + + it('should fail when no repo is configured', async () => { + const puprc = getPuprc(); + delete (puprc as Record).repo; + // Remove package.json temporarily so it can't infer repo + const pkgPath = path.join(fakeProjectDir, 'package.json'); + const pkgBackup = fs.readFileSync(pkgPath, 'utf-8'); + fs.writeFileSync(pkgPath, JSON.stringify({ name: 'fake-project', version: '1.0.0' })); + writePuprc(puprc); + + const result = await runPup('clone'); + // Should fail since no repo URL can be determined + expect(result.exitCode).not.toBe(0); + + // Restore package.json + fs.writeFileSync(pkgPath, pkgBackup); + }); + + it('should clone a repository using https URL', async () => { + const puprc = getPuprc({ repo: 'https://github.com/stellarwp/pup.git' }); + writePuprc(puprc); + + const result = await runPup('clone'); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Cloned'); + + const buildDir = path.join(fakeProjectDir, '.pup-build'); + expect(fs.existsSync(buildDir)).toBe(true); + }); + + it('should clone a repository using shorthand format', async () => { + // Config.getRepo() converts "stellarwp/pup" to "git@github.com:stellarwp/pup.git" + const puprc = getPuprc({ repo: 'stellarwp/pup' }); + writePuprc(puprc); + + const result = await runPup('clone'); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Cloned'); + + const buildDir = path.join(fakeProjectDir, '.pup-build'); + expect(fs.existsSync(buildDir)).toBe(true); + }); + + it('should remove existing build directory before cloning', async () => { + const buildDir = path.join(fakeProjectDir, '.pup-build'); + fs.ensureDirSync(buildDir); + fs.writeFileSync(path.join(buildDir, 'stale-file.txt'), 'old content'); + + const puprc = getPuprc({ repo: `file://${pupRoot}` }); + writePuprc(puprc); + + const result = await runPup('clone'); + expect(result.exitCode).toBe(0); + + // Stale file should be gone, replaced by fresh clone + expect(fs.existsSync(path.join(buildDir, 'stale-file.txt'))).toBe(false); + expect(fs.existsSync(buildDir)).toBe(true); + }); +}); From f03b028f9ceeed428dbc581df9621acb732551a4 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Wed, 11 Feb 2026 12:42:29 -0500 Subject: [PATCH 3/5] ENG-219: Use createTempProject in clone tests for isolation Co-Authored-By: Claude Opus 4.6 --- tests/commands/clone.test.ts | 51 ++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/tests/commands/clone.test.ts b/tests/commands/clone.test.ts index d8ac0f0..640b092 100644 --- a/tests/commands/clone.test.ts +++ b/tests/commands/clone.test.ts @@ -2,41 +2,42 @@ import path from 'node:path'; import fs from 'fs-extra'; import { runPup, - resetFixtures, writePuprc, getPuprc, - fakeProjectDir, + createTempProject, + cleanupTempProjects, pupRoot, } from '../helpers/setup.js'; describe('clone command', () => { + let projectDir: string; + + beforeEach(() => { + projectDir = createTempProject(); + }); + afterEach(() => { - resetFixtures(); - // Clean up cloned build directory - const buildDir = path.join(fakeProjectDir, '.pup-build'); - if (fs.existsSync(buildDir)) { - fs.removeSync(buildDir); - } + cleanupTempProjects(); }); it('should clone a repository using file:// protocol', async () => { const puprc = getPuprc({ repo: `file://${pupRoot}` }); - writePuprc(puprc); + writePuprc(puprc, projectDir); - const result = await runPup('clone'); + const result = await runPup('clone', { cwd: projectDir }); expect(result.exitCode).toBe(0); expect(result.output).toContain('Cloning repository...'); expect(result.output).toContain('Cloned'); - const buildDir = path.join(fakeProjectDir, '.pup-build'); + const buildDir = path.join(projectDir, '.pup-build'); expect(fs.existsSync(buildDir)).toBe(true); }); it('should clone a specific branch', async () => { const puprc = getPuprc({ repo: `file://${pupRoot}` }); - writePuprc(puprc); + writePuprc(puprc, projectDir); - const result = await runPup('clone --branch main'); + const result = await runPup('clone --branch main', { cwd: projectDir }); expect(result.exitCode).toBe(0); expect(result.output).toContain('Cloned'); }); @@ -45,12 +46,12 @@ describe('clone command', () => { const puprc = getPuprc(); delete (puprc as Record).repo; // Remove package.json temporarily so it can't infer repo - const pkgPath = path.join(fakeProjectDir, 'package.json'); + const pkgPath = path.join(projectDir, 'package.json'); const pkgBackup = fs.readFileSync(pkgPath, 'utf-8'); fs.writeFileSync(pkgPath, JSON.stringify({ name: 'fake-project', version: '1.0.0' })); - writePuprc(puprc); + writePuprc(puprc, projectDir); - const result = await runPup('clone'); + const result = await runPup('clone', { cwd: projectDir }); // Should fail since no repo URL can be determined expect(result.exitCode).not.toBe(0); @@ -60,38 +61,38 @@ describe('clone command', () => { it('should clone a repository using https URL', async () => { const puprc = getPuprc({ repo: 'https://github.com/stellarwp/pup.git' }); - writePuprc(puprc); + writePuprc(puprc, projectDir); - const result = await runPup('clone'); + const result = await runPup('clone', { cwd: projectDir }); expect(result.exitCode).toBe(0); expect(result.output).toContain('Cloned'); - const buildDir = path.join(fakeProjectDir, '.pup-build'); + const buildDir = path.join(projectDir, '.pup-build'); expect(fs.existsSync(buildDir)).toBe(true); }); it('should clone a repository using shorthand format', async () => { // Config.getRepo() converts "stellarwp/pup" to "git@github.com:stellarwp/pup.git" const puprc = getPuprc({ repo: 'stellarwp/pup' }); - writePuprc(puprc); + writePuprc(puprc, projectDir); - const result = await runPup('clone'); + const result = await runPup('clone', { cwd: projectDir }); expect(result.exitCode).toBe(0); expect(result.output).toContain('Cloned'); - const buildDir = path.join(fakeProjectDir, '.pup-build'); + const buildDir = path.join(projectDir, '.pup-build'); expect(fs.existsSync(buildDir)).toBe(true); }); it('should remove existing build directory before cloning', async () => { - const buildDir = path.join(fakeProjectDir, '.pup-build'); + const buildDir = path.join(projectDir, '.pup-build'); fs.ensureDirSync(buildDir); fs.writeFileSync(path.join(buildDir, 'stale-file.txt'), 'old content'); const puprc = getPuprc({ repo: `file://${pupRoot}` }); - writePuprc(puprc); + writePuprc(puprc, projectDir); - const result = await runPup('clone'); + const result = await runPup('clone', { cwd: projectDir }); expect(result.exitCode).toBe(0); // Stale file should be gone, replaced by fresh clone From ed3eb382f3e54d70a8d512b3dc6b80ea6c2dba98 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Wed, 11 Feb 2026 12:58:34 -0500 Subject: [PATCH 4/5] ENG-219: Fix clone tests for CI environment Use current branch instead of hardcoded main for branch test. Rewrite SSH URLs to HTTPS in CI for shorthand format test. Co-Authored-By: Claude Opus 4.6 --- tests/commands/clone.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/commands/clone.test.ts b/tests/commands/clone.test.ts index 640b092..952eaa9 100644 --- a/tests/commands/clone.test.ts +++ b/tests/commands/clone.test.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import { execSync } from 'node:child_process'; import fs from 'fs-extra'; import { runPup, @@ -37,7 +38,9 @@ describe('clone command', () => { const puprc = getPuprc({ repo: `file://${pupRoot}` }); writePuprc(puprc, projectDir); - const result = await runPup('clone --branch main', { cwd: projectDir }); + // Use the current branch so this works in CI where main may not exist locally + const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: pupRoot }).toString().trim(); + const result = await runPup(`clone --branch ${currentBranch}`, { cwd: projectDir }); expect(result.exitCode).toBe(0); expect(result.output).toContain('Cloned'); }); @@ -73,6 +76,11 @@ describe('clone command', () => { it('should clone a repository using shorthand format', async () => { // Config.getRepo() converts "stellarwp/pup" to "git@github.com:stellarwp/pup.git" + // In CI, SSH keys aren't available so rewrite SSH URLs to HTTPS for git + if (process.env.CI) { + execSync('git config --global url."https://github.com/".insteadOf "git@github.com:"'); + } + const puprc = getPuprc({ repo: 'stellarwp/pup' }); writePuprc(puprc, projectDir); @@ -82,6 +90,10 @@ describe('clone command', () => { const buildDir = path.join(projectDir, '.pup-build'); expect(fs.existsSync(buildDir)).toBe(true); + + if (process.env.CI) { + execSync('git config --global --unset url."https://github.com/".insteadOf'); + } }); it('should remove existing build directory before cloning', async () => { From ef03be790332837a5f1c23d8d41df8787d747499 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Wed, 11 Feb 2026 13:02:12 -0500 Subject: [PATCH 5/5] ENG-219: Fix clone branch test for detached HEAD in CI Create a temporary local branch instead of relying on HEAD being a named branch, since GitHub Actions checks out in detached HEAD mode. Co-Authored-By: Claude Opus 4.6 --- tests/commands/clone.test.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/commands/clone.test.ts b/tests/commands/clone.test.ts index 952eaa9..260d577 100644 --- a/tests/commands/clone.test.ts +++ b/tests/commands/clone.test.ts @@ -38,11 +38,18 @@ describe('clone command', () => { const puprc = getPuprc({ repo: `file://${pupRoot}` }); writePuprc(puprc, projectDir); - // Use the current branch so this works in CI where main may not exist locally - const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: pupRoot }).toString().trim(); - const result = await runPup(`clone --branch ${currentBranch}`, { cwd: projectDir }); - expect(result.exitCode).toBe(0); - expect(result.output).toContain('Cloned'); + // Create a temporary branch so we have a known branch name to clone, + // even when CI checks out in detached HEAD mode + const testBranch = 'pup-test-clone-branch'; + execSync(`git branch ${testBranch}`, { cwd: pupRoot }); + + try { + const result = await runPup(`clone --branch ${testBranch}`, { cwd: projectDir }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Cloned'); + } finally { + execSync(`git branch -D ${testBranch}`, { cwd: pupRoot }); + } }); it('should fail when no repo is configured', async () => {