pf 0.0.1 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: read
13
+ id-token: write
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - uses: pnpm/action-setup@v4
18
+ with:
19
+ version: 9
20
+
21
+ - uses: actions/setup-node@v4
22
+ with:
23
+ node-version: '24'
24
+ registry-url: 'https://registry.npmjs.org'
25
+ cache: 'pnpm'
26
+
27
+ - run: pnpm install --frozen-lockfile
28
+ - run: pnpm run build
29
+ - run: npm publish
@@ -0,0 +1 @@
1
+ node scripts/check-version.ts
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # pf
2
+
3
+ A humane utility for git worktrees.
4
+
5
+ ## Install
6
+
7
+ ```
8
+ npm i -g pf
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```
14
+ pf <command> [options]
15
+
16
+ Commands:
17
+ new [name] Create a branch and worktree with the specified name (default: "pf-[hash]")
18
+ open <name> Open a worktree in a subshell
19
+ ls List all tracked worktrees
20
+ rm <name> Remove a worktree
21
+
22
+ Options:
23
+ --help, -h Show help
24
+ --version, -v Show version
25
+ ```
26
+
27
+ ## Why `pf`?
28
+
29
+ In agentic development, you often want to spin up isolated workspaces for specific tasks—without stashing or committing your current changes. pf makes this effortless.
30
+
31
+ Create a new worktree for a task:
32
+
33
+ ```
34
+ $ pf new add-login-button
35
+ ```
36
+
37
+ This creates a branch and worktree, then drops you into a subshell. Open it in your editor or point an AI coding agent at it. Your changes are completely independent of any other work on your machine.
38
+
39
+ When you're done, push to GitHub and create a PR:
40
+
41
+ ```
42
+ $ git push -u origin add-login-button
43
+ ```
44
+
45
+ Or merge directly back into main:
46
+
47
+ ```
48
+ $ git checkout main && git merge add-login-button
49
+ ```
50
+
51
+ Then just `exit` to pop out of the worktree and back to your main repo. Clean up when you're done:
52
+
53
+ ```
54
+ $ pf rm add-login-button
55
+ ```
56
+
57
+ Use `pf ls` to see your active worktrees, and `pf open <name>` to return to one later.
package/build.ts ADDED
@@ -0,0 +1,24 @@
1
+ import * as esbuild from "esbuild";
2
+
3
+ const watch = process.argv.includes("--watch");
4
+
5
+ const options: esbuild.BuildOptions = {
6
+ entryPoints: ["index.ts"],
7
+ bundle: true,
8
+ platform: "node",
9
+ target: "node24",
10
+ format: "esm",
11
+ outfile: "dist/index.js",
12
+ banner: {
13
+ js: "#!/usr/bin/env node",
14
+ },
15
+ };
16
+
17
+ if (watch) {
18
+ const ctx = await esbuild.context(options);
19
+ await ctx.watch();
20
+ console.log("Watching...");
21
+ } else {
22
+ await esbuild.build(options);
23
+ console.log("Built dist/index.js");
24
+ }
@@ -0,0 +1,197 @@
1
+ import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync } from "fs";
2
+ import { dirname, join } from "path";
3
+ import { homedir } from "os";
4
+ import { createInterface } from "readline";
5
+ import { success, dim } from "../utils.js";
6
+
7
+ function detectShell(): string | null {
8
+ const shell = process.env.SHELL || "";
9
+ if (shell.includes("zsh")) return "zsh";
10
+ if (shell.includes("bash")) return "bash";
11
+ if (shell.includes("fish")) return "fish";
12
+ return null;
13
+ }
14
+
15
+ function getCompletionPath(shell: string): string {
16
+ const home = homedir();
17
+ switch (shell) {
18
+ case "zsh":
19
+ return join(home, ".zsh/completions/_pf");
20
+ case "bash":
21
+ return join(home, ".bash_completion.d/pf");
22
+ case "fish":
23
+ return join(home, ".config/fish/completions/pf.fish");
24
+ default:
25
+ return "";
26
+ }
27
+ }
28
+
29
+ function getRcConfig(shell: string): { path: string; lines: string[] } | null {
30
+ const home = homedir();
31
+ switch (shell) {
32
+ case "zsh":
33
+ return {
34
+ path: join(home, ".zshrc"),
35
+ lines: [
36
+ 'fpath=(~/.zsh/completions $fpath)',
37
+ 'autoload -Uz compinit && compinit',
38
+ ],
39
+ };
40
+ case "bash":
41
+ return {
42
+ path: join(home, ".bashrc"),
43
+ lines: ['source ~/.bash_completion.d/pf'],
44
+ };
45
+ case "fish":
46
+ return null; // Fish auto-loads from completions dir
47
+ default:
48
+ return null;
49
+ }
50
+ }
51
+
52
+ function fileContains(path: string, substr: string): boolean {
53
+ try {
54
+ const data = readFileSync(path, "utf-8");
55
+ return data.includes(substr);
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ function getCompletionScript(shell: string): string {
62
+ switch (shell) {
63
+ case "zsh":
64
+ return `#compdef pf
65
+
66
+ _pf() {
67
+ local -a commands worktrees
68
+ commands=(
69
+ 'new:Create a new worktree with a branch'
70
+ 'open:Open a worktree'
71
+ 'ls:List all tracked worktrees'
72
+ 'rm:Remove a worktree'
73
+ 'completion:Install shell completions'
74
+ )
75
+
76
+ _arguments -C \\
77
+ "1: :->command" \\
78
+ "*::arg:->args"
79
+
80
+ case "$state" in
81
+ command)
82
+ _describe 'command' commands
83
+ ;;
84
+ args)
85
+ case "$words[1]" in
86
+ open|rm)
87
+ worktrees=(\${(f)"\$(pf ls --plain 2>/dev/null | cut -f1)"})
88
+ _describe 'worktree' worktrees
89
+ ;;
90
+ esac
91
+ ;;
92
+ esac
93
+ }
94
+
95
+ _pf "$@"
96
+ `;
97
+ case "bash":
98
+ return `_pf() {
99
+ local cur="\${COMP_WORDS[COMP_CWORD]}"
100
+ local prev="\${COMP_WORDS[COMP_CWORD-1]}"
101
+ local commands="new open ls rm completion"
102
+
103
+ case "$prev" in
104
+ open|rm)
105
+ local worktrees="\$(pf ls --plain 2>/dev/null | cut -f1)"
106
+ COMPREPLY=($(compgen -W "$worktrees" -- "$cur"))
107
+ return
108
+ ;;
109
+ esac
110
+
111
+ COMPREPLY=($(compgen -W "$commands" -- "$cur"))
112
+ }
113
+ complete -F _pf pf
114
+ `;
115
+ case "fish":
116
+ return `complete -c pf -n "__fish_use_subcommand" -a new -d "Create a new worktree with a branch"
117
+ complete -c pf -n "__fish_use_subcommand" -a open -d "Open a worktree"
118
+ complete -c pf -n "__fish_use_subcommand" -a ls -d "List all tracked worktrees"
119
+ complete -c pf -n "__fish_use_subcommand" -a rm -d "Remove a worktree"
120
+ complete -c pf -n "__fish_use_subcommand" -a completion -d "Install shell completions"
121
+ complete -c pf -n "__fish_seen_subcommand_from open rm" -a "(pf ls --plain 2>/dev/null | cut -f1)" -d "worktree"
122
+ `;
123
+ default:
124
+ return "";
125
+ }
126
+ }
127
+
128
+ async function prompt(question: string): Promise<string> {
129
+ const rl = createInterface({
130
+ input: process.stdin,
131
+ output: process.stdout,
132
+ });
133
+ return new Promise((resolve) => {
134
+ rl.question(question, (answer) => {
135
+ rl.close();
136
+ resolve(answer);
137
+ });
138
+ });
139
+ }
140
+
141
+ export async function completionCommand(shell?: string): Promise<void> {
142
+ // If shell specified, output script to stdout
143
+ if (shell) {
144
+ const script = getCompletionScript(shell);
145
+ if (!script) {
146
+ console.error(`Unknown shell: ${shell}`);
147
+ process.exit(1);
148
+ }
149
+ console.log(script);
150
+ return;
151
+ }
152
+
153
+ // Interactive mode
154
+ const detectedShell = detectShell();
155
+ if (!detectedShell) {
156
+ console.error("Could not detect shell. Run: pf completion [bash|zsh|fish]");
157
+ process.exit(1);
158
+ }
159
+
160
+ const completionPath = getCompletionPath(detectedShell);
161
+ const rcConfig = getRcConfig(detectedShell);
162
+
163
+ let desc = `1. Write completion script to ${completionPath}\n`;
164
+ if (rcConfig && !fileContains(rcConfig.path, rcConfig.lines[0])) {
165
+ desc += `2. Add to ${rcConfig.path}:\n`;
166
+ for (const line of rcConfig.lines) {
167
+ desc += ` ${line}\n`;
168
+ }
169
+ }
170
+
171
+ console.log(`Install ${detectedShell} completions? This will:`);
172
+ console.log(desc);
173
+ const response = await prompt("Proceed? [y/N] ");
174
+
175
+ if (response.toLowerCase() !== "y" && response.toLowerCase() !== "yes") {
176
+ console.log("Canceled.");
177
+ return;
178
+ }
179
+
180
+ // Write completion script
181
+ mkdirSync(dirname(completionPath), { recursive: true });
182
+ writeFileSync(completionPath, getCompletionScript(detectedShell));
183
+ console.log(success("✓"), "Wrote", completionPath);
184
+
185
+ // Update rc file if needed
186
+ if (rcConfig && !fileContains(rcConfig.path, rcConfig.lines[0])) {
187
+ appendFileSync(rcConfig.path, "\n# pf completions\n" + rcConfig.lines.join("\n") + "\n");
188
+ console.log(success("✓"), "Updated", rcConfig.path);
189
+ }
190
+
191
+ console.log();
192
+ if (rcConfig) {
193
+ console.log("Restart your shell or run:", dim(`source ${rcConfig.path}`));
194
+ } else {
195
+ console.log("Restart your shell to enable completions.");
196
+ }
197
+ }
package/commands/ls.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { resolve } from "path";
2
+ import Table from "cli-table3";
3
+ import pc from "picocolors";
4
+ import { loadStore } from "../store.js";
5
+ import { getWorktreeBranch, getWorktreeStatus } from "../utils.js";
6
+
7
+ export function lsCommand(plain: boolean = false): void {
8
+ const store = loadStore();
9
+
10
+ if (store.worktrees.length === 0) {
11
+ console.log("No worktrees. Use 'pf new <name>' to create one.");
12
+ return;
13
+ }
14
+
15
+ // Detect current worktree
16
+ const cwd = process.cwd();
17
+ let currentWorktree = "";
18
+ for (const wt of store.worktrees) {
19
+ const absPath = resolve(wt.path);
20
+ if (cwd.startsWith(absPath)) {
21
+ currentWorktree = wt.name;
22
+ break;
23
+ }
24
+ }
25
+
26
+ if (plain) {
27
+ for (const wt of store.worktrees) {
28
+ const branch = getWorktreeBranch(wt.path);
29
+ const status = getWorktreeStatus(wt.path);
30
+ console.log(`${wt.name}\t${branch}\t${status}`);
31
+ }
32
+ return;
33
+ }
34
+
35
+ const table = new Table({
36
+ head: ["name", "branch", "git status"],
37
+ style: { head: [], border: [] },
38
+ chars: { mid: "", "left-mid": "", "mid-mid": "", "right-mid": "" },
39
+ });
40
+
41
+ for (const wt of store.worktrees) {
42
+ const branch = getWorktreeBranch(wt.path);
43
+ const status = getWorktreeStatus(wt.path);
44
+ const name = wt.name === currentWorktree ? `${pc.green("*")}${wt.name}` : wt.name;
45
+ table.push([name, branch, status]);
46
+ }
47
+
48
+ console.log(table.toString());
49
+ }
@@ -0,0 +1,82 @@
1
+ import { basename, dirname, join } from "path";
2
+ import { mkdirSync } from "fs";
3
+ import {
4
+ loadStore,
5
+ saveStore,
6
+ addWorktree,
7
+ findWorktree,
8
+ getGitRoot,
9
+ getGitCommonDir,
10
+ } from "../store.js";
11
+ import {
12
+ branchExists,
13
+ isInsideWorktree,
14
+ getCurrentBranch,
15
+ createWorktree,
16
+ generateName,
17
+ spawnShell,
18
+ success,
19
+ bold,
20
+ dim,
21
+ } from "../utils.js";
22
+
23
+ export function newCommand(name?: string): void {
24
+ const worktreeName = name ?? generateName();
25
+
26
+ // Check if inside a worktree
27
+ if (isInsideWorktree()) {
28
+ console.error("Error: You are inside a pf worktree.");
29
+ console.error("");
30
+ console.error("Each time you run 'pf new' or 'pf open', pf starts a subshell in the");
31
+ console.error("worktree directory. To keep subshells shallow and avoid nesting, pf");
32
+ console.error("requires you to return to the main repo before opening another worktree.");
33
+ console.error("");
34
+ console.error("To exit the current worktree:");
35
+ console.error(" exit");
36
+ console.error("");
37
+ console.error("Then re-run:");
38
+ console.error(` pf new ${worktreeName}`);
39
+ process.exit(1);
40
+ }
41
+
42
+ // Check if branch already exists
43
+ if (branchExists(worktreeName)) {
44
+ console.error(`Error: branch '${worktreeName}' already exists`);
45
+ process.exit(1);
46
+ }
47
+
48
+ // Check if worktree already exists in store
49
+ const store = loadStore();
50
+ if (findWorktree(store, worktreeName)) {
51
+ console.error(`Error: worktree '${worktreeName}' already exists - use 'pf open ${worktreeName}'`);
52
+ process.exit(1);
53
+ }
54
+
55
+ const baseBranch = getCurrentBranch();
56
+ const gitRoot = getGitRoot();
57
+ const gitDir = getGitCommonDir();
58
+ const repoName = basename(gitRoot);
59
+ const worktreePath = join(gitDir, "pf", "worktrees", worktreeName, repoName);
60
+
61
+ // Create directory
62
+ mkdirSync(dirname(worktreePath), { recursive: true });
63
+
64
+ // Create worktree
65
+ createWorktree(worktreeName, worktreePath);
66
+
67
+ // Track in store
68
+ addWorktree(store, worktreeName, worktreePath);
69
+ saveStore(store);
70
+
71
+ console.log();
72
+ console.log(success(bold(worktreeName)), dim(`(from ${baseBranch})`));
73
+ console.log(dim(" Subshell started. Type 'exit' to return."));
74
+ console.log();
75
+
76
+ // Spawn shell
77
+ spawnShell(worktreePath);
78
+
79
+ console.log();
80
+ console.log(dim(" Back in main worktree"));
81
+ console.log();
82
+ }
@@ -0,0 +1,61 @@
1
+ import { existsSync } from "fs";
2
+ import { loadStore, findWorktree } from "../store.js";
3
+ import {
4
+ isInsideWorktree,
5
+ hasUncommittedChanges,
6
+ getWorktreeBranch,
7
+ spawnShell,
8
+ success,
9
+ bold,
10
+ dim,
11
+ } from "../utils.js";
12
+
13
+ export function openCommand(name: string): void {
14
+ // Check if inside a worktree
15
+ if (isInsideWorktree()) {
16
+ console.error("Error: You are inside a pf worktree.");
17
+ console.error("");
18
+ console.error("Each time you run 'pf new' or 'pf open', pf starts a subshell in the");
19
+ console.error("worktree directory. To keep subshells shallow and avoid nesting, pf");
20
+ console.error("requires you to return to the main repo before opening another worktree.");
21
+ console.error("");
22
+ console.error("To exit the current worktree:");
23
+ console.error(" exit");
24
+ console.error("");
25
+ console.error("Then re-run:");
26
+ console.error(` pf open ${name}`);
27
+ process.exit(1);
28
+ }
29
+
30
+ // Check for uncommitted changes
31
+ if (hasUncommittedChanges()) {
32
+ console.error("Error: uncommitted changes in current worktree - commit or stash first");
33
+ process.exit(1);
34
+ }
35
+
36
+ const store = loadStore();
37
+ const wt = findWorktree(store, name);
38
+
39
+ if (!wt) {
40
+ console.error(`Error: worktree '${name}' not found`);
41
+ process.exit(1);
42
+ }
43
+
44
+ if (!existsSync(wt.path)) {
45
+ console.error(`Error: worktree directory does not exist: ${wt.path}`);
46
+ process.exit(1);
47
+ }
48
+
49
+ const branch = getWorktreeBranch(wt.path);
50
+
51
+ console.log();
52
+ console.log(success(bold(name)), dim(`(${branch})`));
53
+ console.log(dim(" Subshell started. Type 'exit' to return."));
54
+ console.log();
55
+
56
+ spawnShell(wt.path);
57
+
58
+ console.log();
59
+ console.log(dim(" Back in main worktree"));
60
+ console.log();
61
+ }
package/commands/rm.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { resolve } from "path";
2
+ import { loadStore, saveStore, findWorktree, removeWorktree as removeFromStore } from "../store.js";
3
+ import {
4
+ getWorktreeBranch,
5
+ removeWorktree,
6
+ pruneWorktrees,
7
+ deleteBranch,
8
+ success,
9
+ bold,
10
+ dim,
11
+ } from "../utils.js";
12
+
13
+ export function rmCommand(name: string, deleteBranchFlag: boolean = false): void {
14
+ const store = loadStore();
15
+ const wt = findWorktree(store, name);
16
+
17
+ if (!wt) {
18
+ console.error(`Error: worktree '${name}' not found`);
19
+ process.exit(1);
20
+ }
21
+
22
+ // Check if we're currently in this worktree
23
+ const cwd = process.cwd();
24
+ const absWtPath = resolve(wt.path);
25
+ const absCwd = resolve(cwd);
26
+ if (absCwd.startsWith(absWtPath)) {
27
+ console.error("Error: cannot remove current worktree - exit first");
28
+ process.exit(1);
29
+ }
30
+
31
+ // Get branch before removing
32
+ const branch = getWorktreeBranch(wt.path);
33
+
34
+ // Remove worktree
35
+ try {
36
+ removeWorktree(wt.path);
37
+ } catch {
38
+ // May already be gone
39
+ }
40
+
41
+ pruneWorktrees();
42
+
43
+ // Delete branch if requested
44
+ if (deleteBranchFlag && branch !== "[missing]" && branch !== "[detached]") {
45
+ try {
46
+ deleteBranch(branch);
47
+ } catch (err) {
48
+ console.error(`Warning: failed to delete branch ${branch}`);
49
+ }
50
+ }
51
+
52
+ // Remove from store
53
+ removeFromStore(store, name);
54
+ saveStore(store);
55
+
56
+ if (deleteBranchFlag && branch !== "[missing]" && branch !== "[detached]") {
57
+ console.log(success("Removed"), bold(name), dim("(branch deleted)"));
58
+ } else {
59
+ console.log(success("Removed"), bold(name));
60
+ }
61
+ }