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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <msg>` (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`
Expand Down Expand Up @@ -115,6 +117,8 @@ npx gitpick owner/repo/blob/main/path/to/file # a file aka blob
npx gitpick <url/shorthand> # default git behavior
npx gitpick <url/shorthand> [target] # with optional target
npx gitpick <url/shorthand> -b [branch/SHA] # branch or commit SHA
npx gitpick <url/shorthand> --init # initialize as a new git repository
npx gitpick <url/shorthand> --commit "init commit" # init + stage + initial commit
npx gitpick <url/shorthand> -o # overwrite if exists
npx gitpick <url/shorthand> -r # clone submodules
npx gitpick <url/shorthand> -w 30s # sync every 30 seconds
Expand All @@ -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 <msg> 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
Expand All @@ -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
Expand Down
50 changes: 49 additions & 1 deletion bin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <msg>")} 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
Expand All @@ -54,7 +56,7 @@ ${bold("Examples:")}
$ gitpick <url> --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) => {
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -384,6 +428,7 @@ const main = async () => {
await printTree(targetDir)
process.stdout.write("\n")
}
await initGitRepo(targetDir, options)
process.exit(0)
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion bin/utils/transform-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
{
Expand Down Expand Up @@ -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

Expand Down
120 changes: 120 additions & 0 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})

// =====================================================================
Expand Down