Skip to content

Commit 78e63dd

Browse files
nrjdalalclaude
andauthored
feat: local directory interactive mode (#59)
* 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> * fix: edge cases for local interactive mode - Detect local paths with dots (e.g. hello.txt) when -i is set - Skip common heavy dirs: .next, dist, build, .cache, coverage, etc. - Gracefully skip unreadable directories and files - Error when target is inside source directory - Better isLocalPath detection (allow dots in names) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use git ls-files to respect .gitignore for local interactive Uses git ls-files --cached --others --exclude-standard to list files respecting .gitignore. Falls back to manual walk (skipping .git only) when not in a git repo. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: error on missing source with explicit target, preserve symlinks in copy - gitpick -i missing-dir out now errors instead of silently rewriting - Individual symlink selection preserves the symlink instead of dereferencing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove existing dest before symlink creation, don't count failures Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: warn on symlink copy failure instead of swallowing silently Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 687bfcc commit 78e63dd

1 file changed

Lines changed: 231 additions & 3 deletions

File tree

bin/index.ts

Lines changed: 231 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,229 @@ 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 &&
176+
!url.includes("/") &&
177+
!url.startsWith("http") &&
178+
!url.startsWith("git@"))
179+
180+
if (isLocalPath && options.interactive) {
181+
// Single positional that doesn't exist — treat as target (e.g. `gitpick -i hello`)
182+
// Only when no explicit target is given; with two args, a missing source is an error
183+
if (
184+
!fs.existsSync(path.resolve(url.startsWith("~/") ? url.replace("~", os.homedir()) : url))
185+
) {
186+
if (target) {
187+
throw new Error(`Directory not found: ${url}`)
188+
}
189+
target = url
190+
url = "."
191+
}
192+
if (!process.stdout.isTTY) {
193+
throw new Error("Interactive mode requires a TTY")
194+
}
195+
196+
const resolvedSource = path.resolve(
197+
url.startsWith("~/") ? url.replace("~", os.homedir()) : url,
198+
)
199+
200+
if (!fs.existsSync(resolvedSource)) {
201+
throw new Error(`Directory not found: ${url}`)
202+
}
203+
if (!fs.statSync(resolvedSource).isDirectory()) {
204+
throw new Error(`Not a directory: ${url}`)
205+
}
206+
207+
const targetDir = target ? path.resolve(target) : null
208+
209+
const entries: TreeEntry[] = []
210+
211+
// Try git ls-files first (respects .gitignore)
212+
let usedGit = false
213+
try {
214+
const result = await spawn(
215+
"git",
216+
["ls-files", "--cached", "--others", "--exclude-standard"],
217+
{
218+
cwd: resolvedSource,
219+
},
220+
)
221+
const files = result.stdout.trim().split("\n").filter(Boolean)
222+
for (const file of files) {
223+
const parts = file.split("/")
224+
// Add parent directories
225+
for (let i = 1; i < parts.length; i++) {
226+
const dirPath = parts.slice(0, i).join("/")
227+
if (!entries.some((e) => e.path === dirPath)) {
228+
entries.push({ path: dirPath, type: "tree" })
229+
}
230+
}
231+
const filePath = path.join(resolvedSource, file)
232+
try {
233+
const stat = await fs.promises.lstat(filePath)
234+
if (stat.isSymbolicLink()) {
235+
const linkTarget = await fs.promises.readlink(filePath)
236+
let resolvedIsDir = false
237+
try {
238+
resolvedIsDir = (await fs.promises.stat(filePath)).isDirectory()
239+
} catch {}
240+
entries.push({
241+
path: file,
242+
type: "symlink",
243+
linkTarget: resolvedIsDir ? linkTarget + "/" : linkTarget,
244+
})
245+
} else {
246+
entries.push({ path: file, type: "blob", size: stat.size })
247+
}
248+
} catch {}
249+
}
250+
usedGit = true
251+
} catch {}
252+
253+
// Fallback: walk directory manually (skip .git only)
254+
if (!usedGit) {
255+
async function walkLocal(dir: string, rel: string) {
256+
let items
257+
try {
258+
items = await fs.promises.readdir(dir, { withFileTypes: true })
259+
} catch {
260+
return
261+
}
262+
for (const item of items) {
263+
if (item.name === ".git") continue
264+
const itemRel = rel ? `${rel}/${item.name}` : item.name
265+
const itemPath = path.join(dir, item.name)
266+
if (item.isSymbolicLink()) {
267+
const linkTarget = await fs.promises.readlink(itemPath)
268+
let resolvedIsDir = false
269+
try {
270+
resolvedIsDir = (await fs.promises.stat(itemPath)).isDirectory()
271+
} catch {}
272+
entries.push({
273+
path: itemRel,
274+
type: "symlink",
275+
linkTarget: resolvedIsDir ? linkTarget + "/" : linkTarget,
276+
})
277+
} else if (item.isDirectory()) {
278+
entries.push({ path: itemRel, type: "tree" })
279+
await walkLocal(itemPath, itemRel)
280+
} else {
281+
try {
282+
const stat = await fs.promises.stat(itemPath)
283+
entries.push({ path: itemRel, type: "blob", size: stat.size })
284+
} catch {}
285+
}
286+
}
287+
}
288+
await walkLocal(resolvedSource, "")
289+
}
290+
291+
if (!entries.length) {
292+
console.log(yellow("\nDirectory is empty."))
293+
process.exit(0)
294+
}
295+
296+
const selected = await interactivePicker(
297+
entries,
298+
`${displayPath(resolvedSource)}`,
299+
resolvedSource,
300+
)
301+
302+
if (!selected.length) {
303+
console.log("\nNo files selected.")
304+
process.exit(0)
305+
}
306+
307+
if (options.dryRun) {
308+
console.log(
309+
`\n${green("✔")} Would pick ${selected.length} path${selected.length !== 1 ? "s" : ""}:`,
310+
)
311+
for (const sel of selected) console.log(` ${sel}`)
312+
console.log()
313+
process.exit(0)
314+
}
315+
316+
if (!targetDir) {
317+
// No target - just list selected paths
318+
console.log(
319+
`\n${green("✔")} Selected ${selected.length} path${selected.length !== 1 ? "s" : ""}:`,
320+
)
321+
for (const sel of selected) console.log(` ${sel}`)
322+
console.log()
323+
process.exit(0)
324+
}
325+
326+
const resolvedTarget = path.resolve(targetDir)
327+
if (resolvedSource === resolvedTarget) {
328+
throw new Error("Source and target directories are the same")
329+
}
330+
if (resolvedTarget.startsWith(resolvedSource + path.sep)) {
331+
throw new Error("Target directory is inside the source directory")
332+
}
333+
334+
console.log(
335+
`\n${green("✔")} Picking ${selected.length} selected path${selected.length !== 1 ? "s" : ""}...`,
336+
)
337+
338+
options.overwrite = options.overwrite || options.force
339+
if (fs.existsSync(targetDir) && !options.overwrite) {
340+
if ((await fs.promises.readdir(targetDir)).length) {
341+
console.log(
342+
`${yellow(`\nWarning: The target directory exists at ${green(target!)} and is not empty. Use ${cyan("-f")} or ${cyan("-o")} to overwrite.`)}`,
343+
)
344+
process.exit(1)
345+
}
346+
}
347+
348+
await fs.promises.mkdir(targetDir, { recursive: true })
349+
350+
let copiedFiles = 0
351+
for (const sel of selected) {
352+
const src = path.join(resolvedSource, sel)
353+
const dest = path.join(targetDir, sel)
354+
const lstat = await fs.promises.lstat(src).catch(() => null)
355+
if (!lstat) continue
356+
357+
await fs.promises.mkdir(path.dirname(dest), { recursive: true })
358+
if (lstat.isSymbolicLink()) {
359+
const linkTarget = await fs.promises.readlink(src)
360+
try {
361+
await fs.promises.rm(dest, { force: true })
362+
await fs.promises.symlink(linkTarget, dest)
363+
copiedFiles++
364+
} catch (err: any) {
365+
console.log(yellow(` Warning: failed to copy symlink ${sel}: ${err.message}`))
366+
}
367+
} else if (lstat.isDirectory()) {
368+
await fs.promises.mkdir(dest, { recursive: true })
369+
const files = await copyDir(src, dest)
370+
copiedFiles += files.length
371+
} else {
372+
await fs.promises.copyFile(src, dest)
373+
copiedFiles++
374+
}
375+
}
376+
377+
console.log(
378+
green(
379+
`✔ Copied ${copiedFiles} file${copiedFiles !== 1 ? "s" : ""} to ${displayPath(targetDir)}`,
380+
),
381+
)
382+
if (options.tree) {
383+
process.stdout.write(`\n${bold(cyan(displayPath(targetDir)))}\n`)
384+
await printTree(targetDir)
385+
process.stdout.write("\n")
386+
}
387+
process.exit(0)
388+
}
389+
162390
const silent = options.tree || options.quiet
163391

164392
if (!silent) {

0 commit comments

Comments
 (0)