diff --git a/README.md b/README.md index 91130da..24af825 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,8 @@ npx gitpick owner/repo -i --dry-run - **šŸ”„ Interactive mode** - browse and cherry-pick files/folders with `-i` | `--interactive` - šŸ” Seamlessly works with both public and private repositories using a PAT - šŸ“¦ Can easily clone all submodules with `-r` | `--recursive` +- 🧱 Initialize cloned output as a new git repo with `--init` +- šŸ’¾ Stage and create an initial git commit with `-m`/`--commit ` (implies `--init`) - šŸ”Ž Preview what would be cloned with `--dry-run` before cloning - 🌳 View cloned file structure as a colored tree with `--tree` - šŸ—‘ļø Overwrite or replace existing files without a prompt using `-o` | `--overwrite` @@ -115,6 +117,8 @@ npx gitpick owner/repo/blob/main/path/to/file # a file aka blob npx gitpick # default git behavior npx gitpick [target] # with optional target npx gitpick -b [branch/SHA] # branch or commit SHA +npx gitpick --init # initialize as a new git repository +npx gitpick --commit "init commit" # init + stage + initial commit npx gitpick -o # overwrite if exists npx gitpick -r # clone submodules npx gitpick -w 30s # sync every 30 seconds @@ -132,6 +136,8 @@ npx gitpick https://bitbucket.org/owner/repo # Bitbucket ``` -b, --branch Branch/SHA to clone + --init Initialize target as a new git repository +-m, --commit Stage all files and create initial git commit -i, --interactive Browse and pick files/folders interactively -n, --dry-run Show what would be cloned without cloning -o, --overwrite Skip overwrite prompt @@ -145,6 +151,8 @@ npx gitpick https://bitbucket.org/owner/repo # Bitbucket -v, --version display the version number ``` +> **Note on `--commit`:** When cloning a single file (`blob`) into an existing non-empty directory, the automated `git add .` command will stage and commit all unrelated files currently present in that directory alongside the cloned file. + --- ## šŸ”„ Interactive Mode diff --git a/bin/index.ts b/bin/index.ts index e835df6..9f84f83 100644 --- a/bin/index.ts +++ b/bin/index.ts @@ -33,6 +33,8 @@ ${bold("Arguments:")} ${bold("Options:")} ${cyan("-b, --branch ")} Branch/SHA to clone + ${cyan(" --init")} Initialize target as a new git repository + ${cyan("-m, --commit ")} Stage all files and create initial git commit ${cyan("-i, --interactive")} Browse and pick files/folders interactively ${cyan("-n, --dry-run")} Show what would be cloned without cloning ${cyan("-o, --overwrite")} Skip overwrite prompt @@ -54,7 +56,7 @@ ${bold("Examples:")} $ gitpick --dry-run $ gitpick https://gitlab.com/owner/repo $ gitpick https://bitbucket.org/owner/repo - + šŸš€ More awesome tools at ${cyan("https://github.com/nrjdalal")}` const displayPath = (targetPath: string) => { @@ -106,17 +108,57 @@ const parse: typeof parseArgs = (config) => { } } +const initGitRepo = async (targetPath: string, options: { init?: boolean; commit?: string }) => { + if (!options.init && !options.commit) return + + const isFile = fs.existsSync(targetPath) && fs.statSync(targetPath).isFile() + const repoPath = isFile ? path.dirname(targetPath) : targetPath + + if (isFile && repoPath === process.cwd()) { + console.log( + `\nāœ– Skipping git init: Cannot initialize a git repository for a single file in the current working directory.`, + ) + return + } + + if (!fs.existsSync(path.join(repoPath, ".git"))) { + await spawn("git", ["init"], { cwd: repoPath }) + } + + if (options.commit) { + await spawn("git", ["add", "."], { cwd: repoPath }) + try { + await spawn("git", ["commit", "-m", options.commit], { cwd: repoPath }) + } catch { + console.log(`\nāœ– git commit failed — configure user.name / user.email`) + } + } +} + const main = async () => { scheduleUpdateCheck() try { + // parseArgs lacks optional strings. To stay zero-dependency, we inject + // a default "init awesomeness" when --commit is passed without a value. + const args = process.argv.slice(2).reduce((acc, arg, i, arr) => { + acc.push(arg) + if ((arg === "-m" || arg === "--commit") && (!arr[i + 1] || arr[i + 1].startsWith("-"))) { + acc.push("init awesomeness") + } + return acc + }, [] as string[]) + const { positionals, values } = parse({ + args, allowPositionals: true, options: { branch: { type: "string", short: "b" }, "dry-run": { type: "boolean", short: "n" }, force: { type: "boolean", short: "f" }, help: { type: "boolean", short: "h" }, + init: { type: "boolean" }, + commit: { type: "string", short: "m" }, interactive: { type: "boolean", short: "i" }, quiet: { type: "boolean", short: "q" }, tree: { type: "boolean" }, @@ -155,6 +197,8 @@ const main = async () => { branch: values.branch, dryRun: values["dry-run"], force: values.force, + init: values.init, + commit: values.commit, interactive: values.interactive, quiet: values.quiet, tree: values.tree, @@ -384,6 +428,7 @@ const main = async () => { await printTree(targetDir) process.stdout.write("\n") } + await initGitRepo(targetDir, options) process.exit(0) } @@ -565,6 +610,7 @@ const main = async () => { `āœ” Copied ${copiedFiles} file${copiedFiles !== 1 ? "s" : ""} to ${displayPath(targetPath)}`, ), ) + await initGitRepo(targetPath, options) if (options.tree) { process.stdout.write(`\n${bold(cyan(displayPath(targetPath)))}\n`) await printTree(targetPath) @@ -624,6 +670,7 @@ const main = async () => { if (!silent) console.log(`\nšŸ‘€ Watching every ${parseTimeString(options.watch) / 1000 + "s"}\n`) await cloneAction(config, options, targetPath) + await initGitRepo(targetPath, options) if (options.tree) await renderTree(targetPath) const watchInterval = parseTimeString(options.watch) setInterval(async () => { @@ -632,6 +679,7 @@ const main = async () => { }, watchInterval) } else { await cloneAction(config, options, targetPath) + await initGitRepo(targetPath, options) if (options.tree) await renderTree(targetPath) notifyUpdate(version, silent) process.exit(0) diff --git a/bin/utils/transform-url.ts b/bin/utils/transform-url.ts index 95a5d2f..9ef84ed 100644 --- a/bin/utils/transform-url.ts +++ b/bin/utils/transform-url.ts @@ -12,6 +12,8 @@ const PREFIXES: { prefix: string; host: Host }[] = [ { prefix: "https://bitbucket.org/", host: "bitbucket.org" }, ] +export type CloneType = "raw" | "blob" | "tree" | "repository" + export async function configFromUrl( url: string, { @@ -64,7 +66,7 @@ export async function configFromUrl( const repoUrl = `https://${token ? token + "@" : token}${host}/${owner}/${repository}` - let type: string + let type: CloneType let resolvedBranch: string let resolvedPath: string diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 796f662..6c5afea 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1033,6 +1033,126 @@ describe("CLI flags", () => { expect(exitCode).toBe(0) expect(parseLine(output)).toContain("nrjdalal/picksuite repository:main") }, 30000) + + it("--help includes --init", async () => { + const { output, exitCode } = await run(["--help"]) + expect(exitCode).toBe(0) + expect(stripAnsi(output)).toContain("--init") + }) +}) + +describe("--init", () => { + it("initializes git repository for directory clone", async () => { + const t = target() + const { exitCode } = await run(["clone", "nrjdalal/picksuite/tree/main/folder", t, "--init"]) + expect(exitCode).toBe(0) + expect(existsSync(join(t, ".git"))).toBe(true) + }, 30000) + + it("initializes git repository in parent dir for blob clone", async () => { + const t = target() + const blobTarget = join(t, "renamed.txt") + const { exitCode } = await run([ + "clone", + "nrjdalal/picksuite/blob/main/file.txt", + blobTarget, + "--init", + ]) + expect(exitCode).toBe(0) + expect(existsSync(join(t, ".git"))).toBe(true) + }, 30000) + + it("creates initial commit for directory clone", async () => { + const t = target() + const { exitCode } = await run([ + "clone", + "nrjdalal/picksuite/tree/main/folder", + t, + "--init", + "--commit", + "Initial commit", + ]) + expect(exitCode).toBe(0) + expect(existsSync(join(t, ".git"))).toBe(true) + const proc = Bun.spawn(["git", "log", "--oneline"], { stdout: "pipe", cwd: t }) + const log = await new Response(proc.stdout).text() + expect(log).toContain("Initial commit") + }, 30000) + + it("creates initial commit in parent dir for blob clone (--commit implies --init)", async () => { + const t = target() + const blobTarget = join(t, "renamed.txt") + const { exitCode } = await run([ + "clone", + "nrjdalal/picksuite/blob/main/file.txt", + blobTarget, + "--commit", + "feat: initial scaffold", + ]) + expect(exitCode).toBe(0) + expect(existsSync(join(t, ".git"))).toBe(true) + const proc = Bun.spawn(["git", "log", "--oneline"], { stdout: "pipe", cwd: t }) + const log = await new Response(proc.stdout).text() + expect(log).toContain("feat: initial scaffold") + }, 30000) + + it("idempotency: calling --init when .git already exists should not error", async () => { + const t = target() + mkdirSync(join(t, ".git"), { recursive: true }) + const { exitCode } = await run([ + "clone", + "nrjdalal/picksuite/tree/main/folder", + t, + "--init", + "-o", + ]) + expect(exitCode).toBe(0) + }, 30000) + + it("--commit without git user config — confirms the error surface", async () => { + const t = target() + const proc = Bun.spawn( + [...CLI, "clone", "nrjdalal/picksuite/tree/main/folder", t, "--commit", "Init"], + { + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + GIT_AUTHOR_NAME: "", + GIT_COMMITTER_NAME: "", + GIT_AUTHOR_EMAIL: "", + GIT_COMMITTER_EMAIL: "", + }, + }, + ) + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + await proc.exited + expect(stdout + stderr).toMatch(/user\.(name|email)/i) + }, 30000) +}) + +describe("watch mode", () => { + it("assert no crash on the second tick", async () => { + const t = target() + const proc = Bun.spawn( + [...CLI, "clone", "nrjdalal/picksuite/tree/main/folder", t, "-w", "1s"], + { + stdout: "pipe", + stderr: "pipe", + }, + ) + + // Wait long enough for a second tick + await new Promise((resolve) => setTimeout(resolve, 1500)) + proc.kill() + const stdout = await new Response(proc.stdout).text() + + // Assert that it didn't crash before being killed + expect(stdout).not.toMatch(/error:/i) + }, 30000) }) // =====================================================================