Skip to content

Commit b4a4629

Browse files
nrjdalalclaude
andcommitted
feat: local directory interactive mode
Browse local directories with -i flag: - gitpick -i (browse cwd) - gitpick -i target (browse cwd, copy to target) - gitpick ./path -i target (browse path, copy to target) - gitpick . -i --dry-run (preview only) Skips node_modules and .git, reuses same picker UI with file preview and syntax highlighting. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 687bfcc commit b4a4629

1 file changed

Lines changed: 158 additions & 3 deletions

File tree

bin/index.ts

Lines changed: 158 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,15 @@ const main = async () => {
134134
process.exit(0)
135135
}
136136

137-
if (await useConfig()) process.exit(0)
137+
// `gitpick -i` with no args — browse cwd
138+
if (values.interactive) {
139+
positionals.push(".")
140+
} else {
141+
if (await useConfig()) process.exit(0)
138142

139-
console.log(helpMessage)
140-
process.exit(0)
143+
console.log(helpMessage)
144+
process.exit(0)
145+
}
141146
}
142147

143148
if (positionals[0] === "clone") {
@@ -159,6 +164,156 @@ const main = async () => {
159164
watch: values.watch,
160165
}
161166

167+
// Local directory interactive mode — detect local paths or
168+
// non-URL-like positionals when -i is set (e.g. `gitpick -i target`)
169+
const isLocalPath =
170+
url === "." ||
171+
url.startsWith("./") ||
172+
url.startsWith("../") ||
173+
url.startsWith("/") ||
174+
url.startsWith("~/") ||
175+
(options.interactive && !url.includes("/") && !url.includes(".") && !url.startsWith("http"))
176+
177+
if (isLocalPath && options.interactive) {
178+
// If url doesn't look like a real path, it's the target (e.g. `gitpick -i hello`)
179+
if (
180+
!fs.existsSync(path.resolve(url.startsWith("~/") ? url.replace("~", os.homedir()) : url))
181+
) {
182+
target = url
183+
url = "."
184+
}
185+
if (!process.stdout.isTTY) {
186+
throw new Error("Interactive mode requires a TTY")
187+
}
188+
189+
const resolvedSource = path.resolve(
190+
url.startsWith("~/") ? url.replace("~", os.homedir()) : url,
191+
)
192+
193+
if (!fs.existsSync(resolvedSource)) {
194+
throw new Error(`Directory not found: ${url}`)
195+
}
196+
if (!fs.statSync(resolvedSource).isDirectory()) {
197+
throw new Error(`Not a directory: ${url}`)
198+
}
199+
200+
const targetDir = target ? path.resolve(target) : null
201+
202+
const entries: TreeEntry[] = []
203+
async function walkLocal(dir: string, rel: string) {
204+
const items = await fs.promises.readdir(dir, { withFileTypes: true })
205+
for (const item of items) {
206+
if (item.name === ".git" || item.name === "node_modules") continue
207+
const itemRel = rel ? `${rel}/${item.name}` : item.name
208+
const itemPath = path.join(dir, item.name)
209+
if (item.isSymbolicLink()) {
210+
const linkTarget = await fs.promises.readlink(itemPath)
211+
let resolvedIsDir = false
212+
try {
213+
resolvedIsDir = (await fs.promises.stat(itemPath)).isDirectory()
214+
} catch {}
215+
entries.push({
216+
path: itemRel,
217+
type: "symlink",
218+
linkTarget: resolvedIsDir ? linkTarget + "/" : linkTarget,
219+
})
220+
} else if (item.isDirectory()) {
221+
entries.push({ path: itemRel, type: "tree" })
222+
await walkLocal(itemPath, itemRel)
223+
} else {
224+
const stat = await fs.promises.stat(itemPath)
225+
entries.push({ path: itemRel, type: "blob", size: stat.size })
226+
}
227+
}
228+
}
229+
await walkLocal(resolvedSource, "")
230+
231+
if (!entries.length) {
232+
console.log(yellow("\nDirectory is empty."))
233+
process.exit(0)
234+
}
235+
236+
const selected = await interactivePicker(
237+
entries,
238+
`${displayPath(resolvedSource)}`,
239+
resolvedSource,
240+
)
241+
242+
if (!selected.length) {
243+
console.log("\nNo files selected.")
244+
process.exit(0)
245+
}
246+
247+
if (options.dryRun) {
248+
console.log(
249+
`\n${green("✔")} Would pick ${selected.length} path${selected.length !== 1 ? "s" : ""}:`,
250+
)
251+
for (const sel of selected) console.log(` ${sel}`)
252+
console.log()
253+
process.exit(0)
254+
}
255+
256+
if (!targetDir) {
257+
// No target - just list selected paths
258+
console.log(
259+
`\n${green("✔")} Selected ${selected.length} path${selected.length !== 1 ? "s" : ""}:`,
260+
)
261+
for (const sel of selected) console.log(` ${sel}`)
262+
console.log()
263+
process.exit(0)
264+
}
265+
266+
if (path.resolve(resolvedSource) === path.resolve(targetDir)) {
267+
throw new Error("Source and target directories are the same")
268+
}
269+
270+
console.log(
271+
`\n${green("✔")} Picking ${selected.length} selected path${selected.length !== 1 ? "s" : ""}...`,
272+
)
273+
274+
options.overwrite = options.overwrite || options.force
275+
if (fs.existsSync(targetDir) && !options.overwrite) {
276+
if ((await fs.promises.readdir(targetDir)).length) {
277+
console.log(
278+
`${yellow(`\nWarning: The target directory exists at ${green(target!)} and is not empty. Use ${cyan("-f")} or ${cyan("-o")} to overwrite.`)}`,
279+
)
280+
process.exit(1)
281+
}
282+
}
283+
284+
await fs.promises.mkdir(targetDir, { recursive: true })
285+
286+
let copiedFiles = 0
287+
for (const sel of selected) {
288+
const src = path.join(resolvedSource, sel)
289+
const dest = path.join(targetDir, sel)
290+
const stat = await fs.promises.stat(src).catch(() => null)
291+
if (!stat) continue
292+
293+
if (stat.isDirectory()) {
294+
await fs.promises.mkdir(dest, { recursive: true })
295+
const files = await copyDir(src, dest)
296+
copiedFiles += files.length
297+
} else {
298+
await fs.promises.mkdir(path.dirname(dest), { recursive: true })
299+
await fs.promises.copyFile(src, dest)
300+
copiedFiles++
301+
}
302+
}
303+
304+
console.log(
305+
green(
306+
`✔ Copied ${copiedFiles} file${copiedFiles !== 1 ? "s" : ""} to ${displayPath(targetDir)}`,
307+
),
308+
)
309+
if (options.tree) {
310+
process.stdout.write(`\n${bold(cyan(displayPath(targetDir)))}\n`)
311+
await printTree(targetDir)
312+
process.stdout.write("\n")
313+
}
314+
process.exit(0)
315+
}
316+
162317
const silent = options.tree || options.quiet
163318

164319
if (!silent) {

0 commit comments

Comments
 (0)