the-campfire 0.1.0
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.
- package/bin/cli.ts +182 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/MermaidDiagram-By1J3Whj.js +2 -0
- package/dist/assets/_basePickBy-Dq6gnD-p.js +1 -0
- package/dist/assets/_baseUniq-SpgN6cGc.js +1 -0
- package/dist/assets/arc-D9SEA8dX.js +1 -0
- package/dist/assets/architectureDiagram-VXUJARFQ-CoTcwLeR.js +36 -0
- package/dist/assets/blockDiagram-VD42YOAC-Dvc63voV.js +122 -0
- package/dist/assets/c4Diagram-YG6GDRKO-tuDcG4_4.js +10 -0
- package/dist/assets/channel-CxhcHn7S.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-x1hChYu9.js +1 -0
- package/dist/assets/chunk-55IACEB6-BX6HWnWP.js +1 -0
- package/dist/assets/chunk-B4BG7PRW-B4IlF03G.js +165 -0
- package/dist/assets/chunk-DI55MBZ5-TpYb5rLg.js +220 -0
- package/dist/assets/chunk-FMBD7UC4-DZX4LUPx.js +15 -0
- package/dist/assets/chunk-QN33PNHL-CuQs0NDY.js +1 -0
- package/dist/assets/chunk-QZHKN3VN-CygWwVx8.js +1 -0
- package/dist/assets/chunk-TZMSLE5B-CtI-IdKV.js +1 -0
- package/dist/assets/classDiagram-2ON5EDUG-BM0LptJF.js +1 -0
- package/dist/assets/classDiagram-v2-WZHVMYZB-BM0LptJF.js +1 -0
- package/dist/assets/clone-C6kt1rE7.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-PdySs991.js +1 -0
- package/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
- package/dist/assets/dagre-6UL2VRFP-J0SxEiEu.js +4 -0
- package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/dist/assets/diagram-PSM6KHXK-CW0HLXaH.js +24 -0
- package/dist/assets/diagram-QEK2KX5R-DADHF-k4.js +43 -0
- package/dist/assets/diagram-S2PKOQOG-9S0-PC3S.js +24 -0
- package/dist/assets/erDiagram-Q2GNP2WA-BEHAiY_q.js +60 -0
- package/dist/assets/flowDiagram-NV44I4VS-Cu6n9Xnd.js +162 -0
- package/dist/assets/ganttDiagram-JELNMOA3-Cr_BHryu.js +267 -0
- package/dist/assets/gitGraphDiagram-V2S2FVAM-D5pT35v5.js +65 -0
- package/dist/assets/graph-DKRbrxcs.js +1 -0
- package/dist/assets/index-Byjg9zgI.css +32 -0
- package/dist/assets/index-Cwj3m0hT.js +284 -0
- package/dist/assets/infoDiagram-HS3SLOUP-BaJlrgKV.js +2 -0
- package/dist/assets/init-Gi6I4Gst.js +1 -0
- package/dist/assets/journeyDiagram-XKPGCS4Q-CXC4-DJv.js +139 -0
- package/dist/assets/kanban-definition-3W4ZIXB7-BtHBu9cx.js +89 -0
- package/dist/assets/katex-BlHpptmG.js +261 -0
- package/dist/assets/layout-DnxSwLDJ.js +1 -0
- package/dist/assets/linear-CWbbIT8l.js +1 -0
- package/dist/assets/mermaid.core-DXn1cFAK.js +256 -0
- package/dist/assets/mindmap-definition-VGOIOE7T-BR8br1hs.js +68 -0
- package/dist/assets/ordinal-BENe2yWM.js +1 -0
- package/dist/assets/pieDiagram-ADFJNKIX-B7RmXV7I.js +30 -0
- package/dist/assets/quadrantDiagram-AYHSOK5B-DK0FhyV_.js +7 -0
- package/dist/assets/requirementDiagram-UZGBJVZJ-3zAGxSpL.js +64 -0
- package/dist/assets/sankeyDiagram-TZEHDZUN-DZppKtjc.js +10 -0
- package/dist/assets/sequenceDiagram-WL72ISMW-D1x3r3wV.js +145 -0
- package/dist/assets/stateDiagram-FKZM4ZOC-2ByD08Vq.js +1 -0
- package/dist/assets/stateDiagram-v2-4FDKWEC3-Vm1cYjgV.js +1 -0
- package/dist/assets/timeline-definition-IT6M3QCI-l5S35biR.js +61 -0
- package/dist/assets/treemap-GDKQZRPO-DIPadPiF.js +162 -0
- package/dist/assets/xychartDiagram-PRI3JC2R-9GUvwS2b.js +7 -0
- package/dist/favicon.svg +3 -0
- package/dist/fonts/Geist-Variable.woff2 +0 -0
- package/dist/fonts/GeistMono-Variable.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +33 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo.svg +3 -0
- package/dist/manifest.json +26 -0
- package/dist/og-image.png +0 -0
- package/dist/sw.js +130 -0
- package/package.json +92 -0
- package/server/ADAPTERS.md +121 -0
- package/server/adapter-registry-types.ts +28 -0
- package/server/adapter-registry.ts +293 -0
- package/server/adapter-types.ts +51 -0
- package/server/agent-executor.ts +249 -0
- package/server/agent-mcp-bridge.ts +148 -0
- package/server/agent-mcp-stdio.ts +171 -0
- package/server/agent-mcp-tools.ts +76 -0
- package/server/agent-store.ts +183 -0
- package/server/agent-types.ts +79 -0
- package/server/aider-adapter.ts +525 -0
- package/server/auth.ts +153 -0
- package/server/auto-namer.ts +132 -0
- package/server/capability-discovery.ts +431 -0
- package/server/claude-container-auth.ts +40 -0
- package/server/claude-stdio-adapter.ts +452 -0
- package/server/clawhub-export.ts +218 -0
- package/server/cli-launcher.ts +1613 -0
- package/server/codex-adapter.ts +1987 -0
- package/server/codex-container-auth.ts +40 -0
- package/server/codex-home.ts +26 -0
- package/server/collective-intelligence.ts +401 -0
- package/server/commands-discovery.ts +160 -0
- package/server/constants.ts +5 -0
- package/server/container-manager.ts +328 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.ts +149 -0
- package/server/cron-types.ts +63 -0
- package/server/deliberation-engine.ts +323 -0
- package/server/embedding.ts +89 -0
- package/server/env-manager.ts +146 -0
- package/server/environment-detector.ts +106 -0
- package/server/environment-rules.ts +106 -0
- package/server/gallery-store.ts +216 -0
- package/server/gallery-types.ts +52 -0
- package/server/gallery-votes.ts +101 -0
- package/server/git-utils.ts +384 -0
- package/server/github-pr.ts +379 -0
- package/server/goose-adapter.ts +929 -0
- package/server/image-pull-manager.ts +144 -0
- package/server/index.ts +363 -0
- package/server/linear-project-manager.ts +78 -0
- package/server/moltbook-client.ts +160 -0
- package/server/openclaw-adapter.ts +916 -0
- package/server/openclaw-channel/channel.ts +188 -0
- package/server/openclaw-channel/index.ts +68 -0
- package/server/openclaw-channel/package.json +19 -0
- package/server/openclaw-channel/types.ts +93 -0
- package/server/opencode-adapter.ts +951 -0
- package/server/openhands-adapter.ts +775 -0
- package/server/orchestrator-executor.ts +184 -0
- package/server/orchestrator-store.ts +106 -0
- package/server/orchestrator-types.ts +86 -0
- package/server/path-resolver.ts +168 -0
- package/server/pr-poller.ts +162 -0
- package/server/proactive-keepalive.ts +121 -0
- package/server/prompt-manager.ts +103 -0
- package/server/protocol-monitor.ts +197 -0
- package/server/race-controller.ts +295 -0
- package/server/race-store.ts +82 -0
- package/server/recorder.ts +359 -0
- package/server/recording-hub/compat-validator.ts +122 -0
- package/server/recording-hub/diagnostics.ts +289 -0
- package/server/recording-hub/hub-routes.ts +146 -0
- package/server/recording-hub/hub-store.ts +224 -0
- package/server/replay.ts +78 -0
- package/server/routes/adapter-routes.ts +31 -0
- package/server/routes/agent-mcp-routes.ts +35 -0
- package/server/routes/agent-routes.ts +185 -0
- package/server/routes/auth-routes.ts +101 -0
- package/server/routes/ci-routes.ts +165 -0
- package/server/routes/commands-routes.ts +94 -0
- package/server/routes/cron-routes.ts +100 -0
- package/server/routes/env-routes.ts +50 -0
- package/server/routes/folder-routes.ts +56 -0
- package/server/routes/fs-routes.ts +403 -0
- package/server/routes/gallery-routes.ts +410 -0
- package/server/routes/git-routes.ts +108 -0
- package/server/routes/index.ts +62 -0
- package/server/routes/linear-routes.ts +254 -0
- package/server/routes/monitor-routes.ts +11 -0
- package/server/routes/orchestrator-routes.ts +210 -0
- package/server/routes/prompt-routes.ts +47 -0
- package/server/routes/race-routes.ts +103 -0
- package/server/routes/recording-routes.ts +101 -0
- package/server/routes/route-deps.ts +32 -0
- package/server/routes/session-routes.ts +560 -0
- package/server/routes/settings-routes.ts +108 -0
- package/server/routes/skills-routes.ts +52 -0
- package/server/routes/system-routes.ts +243 -0
- package/server/routes/webhook-routes.ts +135 -0
- package/server/routes.ts +44 -0
- package/server/security-middleware.ts +100 -0
- package/server/semantic-memory.ts +389 -0
- package/server/service.ts +733 -0
- package/server/session-folders.ts +107 -0
- package/server/session-git-info.ts +89 -0
- package/server/session-linear-issues.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-store.ts +169 -0
- package/server/session-types.ts +415 -0
- package/server/settings-manager.ts +120 -0
- package/server/shared-context.ts +401 -0
- package/server/skills-manager.ts +250 -0
- package/server/sub-session-manager.ts +263 -0
- package/server/terminal-manager.ts +185 -0
- package/server/update-checker.ts +114 -0
- package/server/usage-limits.ts +192 -0
- package/server/webhook-manager.ts +228 -0
- package/server/webhook-store.ts +168 -0
- package/server/webhook-types.ts +66 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-bridge.ts +2322 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
// ─── Paths ──────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const GALLERY_DIR = join(homedir(), ".campfire", "gallery");
|
|
9
|
+
const VOTES_FILE = join(GALLERY_DIR, "votes.json");
|
|
10
|
+
|
|
11
|
+
type VoteDirection = 1 | -1;
|
|
12
|
+
type VotesData = Record<string, Record<string, VoteDirection>>;
|
|
13
|
+
|
|
14
|
+
function ensureDir(): void {
|
|
15
|
+
mkdirSync(GALLERY_DIR, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ─── Load / Save ────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function loadVotes(): VotesData {
|
|
21
|
+
try {
|
|
22
|
+
if (!existsSync(VOTES_FILE)) return {};
|
|
23
|
+
const raw = readFileSync(VOTES_FILE, "utf-8");
|
|
24
|
+
return JSON.parse(raw);
|
|
25
|
+
} catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function saveVotes(data: VotesData): void {
|
|
31
|
+
ensureDir();
|
|
32
|
+
writeFileSync(VOTES_FILE, JSON.stringify(data, null, 2), "utf-8");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Hash an IP address for anonymous vote deduplication.
|
|
39
|
+
* Uses SHA-256 so raw IPs are never stored.
|
|
40
|
+
*/
|
|
41
|
+
export function getVoterHash(ip: string): string {
|
|
42
|
+
return createHash("sha256").update(ip).digest("hex").slice(0, 16);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Record a vote (upvote=1, downvote=-1).
|
|
47
|
+
* If the voter already voted the same direction, the vote is removed (toggle).
|
|
48
|
+
* If the voter voted the opposite direction, the vote is flipped.
|
|
49
|
+
* Returns the new total vote count for the entry.
|
|
50
|
+
*/
|
|
51
|
+
export function recordVote(
|
|
52
|
+
entryId: string,
|
|
53
|
+
voterId: string,
|
|
54
|
+
direction: VoteDirection,
|
|
55
|
+
): number {
|
|
56
|
+
const data = loadVotes();
|
|
57
|
+
if (!data[entryId]) {
|
|
58
|
+
data[entryId] = {};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const existing = data[entryId][voterId];
|
|
62
|
+
if (existing === direction) {
|
|
63
|
+
// Toggle off: remove vote
|
|
64
|
+
delete data[entryId][voterId];
|
|
65
|
+
} else {
|
|
66
|
+
// New vote or flip direction
|
|
67
|
+
data[entryId][voterId] = direction;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
saveVotes(data);
|
|
71
|
+
return getVoteCount(entryId, data);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get the total vote count for an entry (sum of all +1/-1 votes).
|
|
76
|
+
*/
|
|
77
|
+
export function getVoteCount(entryId: string, data?: VotesData): number {
|
|
78
|
+
const votes = data || loadVotes();
|
|
79
|
+
const entryVotes = votes[entryId];
|
|
80
|
+
if (!entryVotes) return 0;
|
|
81
|
+
return Object.values(entryVotes).reduce((sum, v) => sum + v, 0);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check if a voter has already voted on an entry.
|
|
86
|
+
*/
|
|
87
|
+
export function hasVoted(entryId: string, voterId: string): VoteDirection | null {
|
|
88
|
+
const data = loadVotes();
|
|
89
|
+
return data[entryId]?.[voterId] ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Remove all votes for a gallery entry (called on entry deletion).
|
|
94
|
+
*/
|
|
95
|
+
export function removeEntryVotes(entryId: string): void {
|
|
96
|
+
const data = loadVotes();
|
|
97
|
+
if (data[entryId]) {
|
|
98
|
+
delete data[entryId];
|
|
99
|
+
saveVotes(data);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { join, basename } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
|
|
6
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface GitRepoInfo {
|
|
9
|
+
repoRoot: string;
|
|
10
|
+
repoName: string;
|
|
11
|
+
currentBranch: string;
|
|
12
|
+
defaultBranch: string;
|
|
13
|
+
isWorktree: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface GitBranchInfo {
|
|
17
|
+
name: string;
|
|
18
|
+
isCurrent: boolean;
|
|
19
|
+
isRemote: boolean;
|
|
20
|
+
worktreePath: string | null;
|
|
21
|
+
ahead: number;
|
|
22
|
+
behind: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface GitWorktreeInfo {
|
|
26
|
+
path: string;
|
|
27
|
+
branch: string;
|
|
28
|
+
head: string;
|
|
29
|
+
isMainWorktree: boolean;
|
|
30
|
+
isDirty: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface WorktreeCreateResult {
|
|
34
|
+
worktreePath: string;
|
|
35
|
+
/** The conceptual branch the user selected */
|
|
36
|
+
branch: string;
|
|
37
|
+
/** The actual git branch in the worktree (may be e.g. `main-wt-2` for duplicate sessions) */
|
|
38
|
+
actualBranch: string;
|
|
39
|
+
isNew: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── Paths ──────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const WORKTREES_BASE = join(homedir(), ".campfire", "worktrees");
|
|
45
|
+
|
|
46
|
+
function sanitizeBranch(branch: string): string {
|
|
47
|
+
return branch.replace(/\//g, "--");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function worktreeDir(repoName: string, branch: string): string {
|
|
51
|
+
return join(WORKTREES_BASE, repoName, sanitizeBranch(branch));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
function git(cmd: string, cwd: string): string {
|
|
57
|
+
return execSync(`git ${cmd}`, {
|
|
58
|
+
cwd,
|
|
59
|
+
encoding: "utf-8",
|
|
60
|
+
timeout: 10_000,
|
|
61
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
62
|
+
}).trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function gitSafe(cmd: string, cwd: string): string | null {
|
|
66
|
+
try {
|
|
67
|
+
return git(cmd, cwd);
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Functions ──────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
export function getRepoInfo(cwd: string): GitRepoInfo | null {
|
|
76
|
+
const repoRoot = gitSafe("rev-parse --show-toplevel", cwd);
|
|
77
|
+
if (!repoRoot) return null;
|
|
78
|
+
|
|
79
|
+
const currentBranch = gitSafe("rev-parse --abbrev-ref HEAD", cwd) || "HEAD";
|
|
80
|
+
const gitDir = gitSafe("rev-parse --git-dir", cwd) || "";
|
|
81
|
+
// A linked worktree's .git dir is inside the main repo's .git/worktrees/
|
|
82
|
+
const isWorktree = gitDir.includes("/worktrees/");
|
|
83
|
+
|
|
84
|
+
const defaultBranch = resolveDefaultBranch(repoRoot);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
repoRoot,
|
|
88
|
+
repoName: basename(repoRoot),
|
|
89
|
+
currentBranch,
|
|
90
|
+
defaultBranch,
|
|
91
|
+
isWorktree,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function resolveDefaultBranch(repoRoot: string): string {
|
|
96
|
+
// Try origin HEAD
|
|
97
|
+
const originRef = gitSafe("symbolic-ref refs/remotes/origin/HEAD", repoRoot);
|
|
98
|
+
if (originRef) {
|
|
99
|
+
return originRef.replace("refs/remotes/origin/", "");
|
|
100
|
+
}
|
|
101
|
+
// Fallback: check if main or master exists
|
|
102
|
+
const branches = gitSafe("branch --list main master", repoRoot) || "";
|
|
103
|
+
if (branches.includes("main")) return "main";
|
|
104
|
+
if (branches.includes("master")) return "master";
|
|
105
|
+
// Last resort
|
|
106
|
+
return "main";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function listBranches(repoRoot: string): GitBranchInfo[] {
|
|
110
|
+
// Get worktree mappings first
|
|
111
|
+
const worktrees = listWorktrees(repoRoot);
|
|
112
|
+
const worktreeByBranch = new Map<string, string>();
|
|
113
|
+
for (const wt of worktrees) {
|
|
114
|
+
if (wt.branch) worktreeByBranch.set(wt.branch, wt.path);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const result: GitBranchInfo[] = [];
|
|
118
|
+
|
|
119
|
+
// Local branches
|
|
120
|
+
const localRaw = gitSafe(
|
|
121
|
+
"for-each-ref '--format=%(refname:short)%09%(HEAD)' refs/heads/",
|
|
122
|
+
repoRoot,
|
|
123
|
+
);
|
|
124
|
+
if (localRaw) {
|
|
125
|
+
for (const line of localRaw.split("\n")) {
|
|
126
|
+
if (!line.trim()) continue;
|
|
127
|
+
const [name, head] = line.split("\t");
|
|
128
|
+
const isCurrent = head?.trim() === "*";
|
|
129
|
+
const { ahead, behind } = getBranchStatus(repoRoot, name);
|
|
130
|
+
result.push({
|
|
131
|
+
name,
|
|
132
|
+
isCurrent,
|
|
133
|
+
isRemote: false,
|
|
134
|
+
worktreePath: worktreeByBranch.get(name) || null,
|
|
135
|
+
ahead,
|
|
136
|
+
behind,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Remote branches (only those without a local counterpart)
|
|
142
|
+
const localNames = new Set(result.map((b) => b.name));
|
|
143
|
+
const remoteRaw = gitSafe(
|
|
144
|
+
"for-each-ref '--format=%(refname:short)' refs/remotes/origin/",
|
|
145
|
+
repoRoot,
|
|
146
|
+
);
|
|
147
|
+
if (remoteRaw) {
|
|
148
|
+
for (const line of remoteRaw.split("\n")) {
|
|
149
|
+
const full = line.trim();
|
|
150
|
+
if (!full || full === "origin/HEAD") continue;
|
|
151
|
+
const name = full.replace("origin/", "");
|
|
152
|
+
if (localNames.has(name)) continue;
|
|
153
|
+
result.push({
|
|
154
|
+
name,
|
|
155
|
+
isCurrent: false,
|
|
156
|
+
isRemote: true,
|
|
157
|
+
worktreePath: null,
|
|
158
|
+
ahead: 0,
|
|
159
|
+
behind: 0,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function listWorktrees(repoRoot: string): GitWorktreeInfo[] {
|
|
168
|
+
const raw = gitSafe("worktree list --porcelain", repoRoot);
|
|
169
|
+
if (!raw) return [];
|
|
170
|
+
|
|
171
|
+
const worktrees: GitWorktreeInfo[] = [];
|
|
172
|
+
let current: Partial<GitWorktreeInfo> = {};
|
|
173
|
+
|
|
174
|
+
for (const line of raw.split("\n")) {
|
|
175
|
+
if (line.startsWith("worktree ")) {
|
|
176
|
+
if (current.path) {
|
|
177
|
+
worktrees.push(current as GitWorktreeInfo);
|
|
178
|
+
}
|
|
179
|
+
current = { path: line.slice(9), isDirty: false, isMainWorktree: false };
|
|
180
|
+
} else if (line.startsWith("HEAD ")) {
|
|
181
|
+
current.head = line.slice(5);
|
|
182
|
+
} else if (line.startsWith("branch ")) {
|
|
183
|
+
current.branch = line.slice(7).replace("refs/heads/", "");
|
|
184
|
+
} else if (line === "bare") {
|
|
185
|
+
current.isMainWorktree = true;
|
|
186
|
+
} else if (line === "") {
|
|
187
|
+
// End of entry — check if main worktree (first one is always main)
|
|
188
|
+
if (worktrees.length === 0 && current.path) {
|
|
189
|
+
current.isMainWorktree = true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Push last entry
|
|
194
|
+
if (current.path) {
|
|
195
|
+
if (worktrees.length === 0) current.isMainWorktree = true;
|
|
196
|
+
worktrees.push(current as GitWorktreeInfo);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Check dirty status for each worktree
|
|
200
|
+
for (const wt of worktrees) {
|
|
201
|
+
wt.isDirty = isWorktreeDirty(wt.path);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return worktrees;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function ensureWorktree(
|
|
208
|
+
repoRoot: string,
|
|
209
|
+
branchName: string,
|
|
210
|
+
options?: { baseBranch?: string; createBranch?: boolean; forceNew?: boolean },
|
|
211
|
+
): WorktreeCreateResult {
|
|
212
|
+
const repoName = basename(repoRoot);
|
|
213
|
+
|
|
214
|
+
// Check if a worktree already exists for this branch
|
|
215
|
+
const existing = listWorktrees(repoRoot);
|
|
216
|
+
const found = existing.find((wt) => wt.branch === branchName);
|
|
217
|
+
|
|
218
|
+
if (found && !options?.forceNew) {
|
|
219
|
+
// Don't reuse the main worktree — it's the original repo checkout
|
|
220
|
+
if (!found.isMainWorktree) {
|
|
221
|
+
return { worktreePath: found.path, branch: branchName, actualBranch: branchName, isNew: false };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Find a unique path: append random 4-digit suffix if the base path is taken
|
|
226
|
+
const basePath = worktreeDir(repoName, branchName);
|
|
227
|
+
let targetPath = basePath;
|
|
228
|
+
for (let attempt = 0; attempt < 100 && existsSync(targetPath); attempt++) {
|
|
229
|
+
const suffix = Math.floor(1000 + Math.random() * 9000);
|
|
230
|
+
targetPath = `${basePath}-${suffix}`;
|
|
231
|
+
}
|
|
232
|
+
if (existsSync(targetPath)) {
|
|
233
|
+
targetPath = `${basePath}-${Date.now()}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Ensure parent directory exists
|
|
237
|
+
mkdirSync(join(WORKTREES_BASE, repoName), { recursive: true });
|
|
238
|
+
|
|
239
|
+
// A worktree already exists for this branch — create a new uniquely-named
|
|
240
|
+
// branch so multiple sessions can work on the same branch independently.
|
|
241
|
+
if (found) {
|
|
242
|
+
const commitHash = git("rev-parse HEAD", found.path);
|
|
243
|
+
const uniqueBranch = generateUniqueWorktreeBranch(repoRoot, branchName);
|
|
244
|
+
git(`worktree add -b ${uniqueBranch} "${targetPath}" ${commitHash}`, repoRoot);
|
|
245
|
+
return { worktreePath: targetPath, branch: branchName, actualBranch: uniqueBranch, isNew: false };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Check if branch already exists locally or on remote
|
|
249
|
+
const branchExists =
|
|
250
|
+
gitSafe(`rev-parse --verify refs/heads/${branchName}`, repoRoot) !== null;
|
|
251
|
+
const remoteBranchExists =
|
|
252
|
+
gitSafe(`rev-parse --verify refs/remotes/origin/${branchName}`, repoRoot) !== null;
|
|
253
|
+
|
|
254
|
+
if (branchExists) {
|
|
255
|
+
if (options?.forceNew) {
|
|
256
|
+
// Create a uniquely-named branch so multiple sessions can work independently
|
|
257
|
+
const commitHash = git(`rev-parse refs/heads/${branchName}`, repoRoot);
|
|
258
|
+
const uniqueBranch = generateUniqueWorktreeBranch(repoRoot, branchName);
|
|
259
|
+
git(`worktree add -b ${uniqueBranch} "${targetPath}" ${commitHash}`, repoRoot);
|
|
260
|
+
return { worktreePath: targetPath, branch: branchName, actualBranch: uniqueBranch, isNew: false };
|
|
261
|
+
}
|
|
262
|
+
// Worktree add with existing local branch
|
|
263
|
+
git(`worktree add "${targetPath}" ${branchName}`, repoRoot);
|
|
264
|
+
return { worktreePath: targetPath, branch: branchName, actualBranch: branchName, isNew: false };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (remoteBranchExists) {
|
|
268
|
+
if (options?.forceNew) {
|
|
269
|
+
const uniqueBranch = generateUniqueWorktreeBranch(repoRoot, branchName);
|
|
270
|
+
git(`worktree add -b ${uniqueBranch} "${targetPath}" origin/${branchName}`, repoRoot);
|
|
271
|
+
return { worktreePath: targetPath, branch: branchName, actualBranch: uniqueBranch, isNew: false };
|
|
272
|
+
}
|
|
273
|
+
// Create local tracking branch from remote
|
|
274
|
+
git(`worktree add -b ${branchName} "${targetPath}" origin/${branchName}`, repoRoot);
|
|
275
|
+
return { worktreePath: targetPath, branch: branchName, actualBranch: branchName, isNew: false };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (options?.createBranch !== false) {
|
|
279
|
+
// Create new branch from base
|
|
280
|
+
const base = options?.baseBranch || resolveDefaultBranch(repoRoot);
|
|
281
|
+
git(`worktree add -b ${branchName} "${targetPath}" ${base}`, repoRoot);
|
|
282
|
+
return { worktreePath: targetPath, branch: branchName, actualBranch: branchName, isNew: true };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
throw new Error(`Branch "${branchName}" does not exist and createBranch is false`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Generate a unique branch name for a campfire-managed worktree.
|
|
290
|
+
* Pattern: `{branch}-wt-{random4digit}` (e.g. `main-wt-8374`).
|
|
291
|
+
* Uses random suffixes to avoid collisions with leftover branches.
|
|
292
|
+
*/
|
|
293
|
+
export function generateUniqueWorktreeBranch(repoRoot: string, baseBranch: string): string {
|
|
294
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
295
|
+
const suffix = Math.floor(1000 + Math.random() * 9000);
|
|
296
|
+
const candidate = `${baseBranch}-wt-${suffix}`;
|
|
297
|
+
if (gitSafe(`rev-parse --verify refs/heads/${candidate}`, repoRoot) === null) {
|
|
298
|
+
return candidate;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// Fallback: use timestamp if all random attempts collide (extremely unlikely)
|
|
302
|
+
return `${baseBranch}-wt-${Date.now()}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function removeWorktree(
|
|
306
|
+
repoRoot: string,
|
|
307
|
+
worktreePath: string,
|
|
308
|
+
options?: { force?: boolean; branchToDelete?: string },
|
|
309
|
+
): { removed: boolean; reason?: string } {
|
|
310
|
+
if (!existsSync(worktreePath)) {
|
|
311
|
+
// Already gone, clean up git's reference
|
|
312
|
+
gitSafe("worktree prune", repoRoot);
|
|
313
|
+
if (options?.branchToDelete) {
|
|
314
|
+
gitSafe(`branch -D ${options.branchToDelete}`, repoRoot);
|
|
315
|
+
}
|
|
316
|
+
return { removed: true };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (!options?.force && isWorktreeDirty(worktreePath)) {
|
|
320
|
+
return {
|
|
321
|
+
removed: false,
|
|
322
|
+
reason: "Worktree has uncommitted changes. Use force to remove anyway.",
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
const forceFlag = options?.force ? " --force" : "";
|
|
328
|
+
git(`worktree remove "${worktreePath}"${forceFlag}`, repoRoot);
|
|
329
|
+
// Clean up the campfire-managed branch after worktree removal
|
|
330
|
+
if (options?.branchToDelete) {
|
|
331
|
+
gitSafe(`branch -D ${options.branchToDelete}`, repoRoot);
|
|
332
|
+
}
|
|
333
|
+
return { removed: true };
|
|
334
|
+
} catch (e: unknown) {
|
|
335
|
+
return {
|
|
336
|
+
removed: false,
|
|
337
|
+
reason: e instanceof Error ? e.message : String(e),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function isWorktreeDirty(worktreePath: string): boolean {
|
|
343
|
+
if (!existsSync(worktreePath)) return false;
|
|
344
|
+
const status = gitSafe("status --porcelain", worktreePath);
|
|
345
|
+
return status !== null && status.length > 0;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function gitFetch(cwd: string): { success: boolean; output: string } {
|
|
349
|
+
try {
|
|
350
|
+
const output = git("fetch --prune", cwd);
|
|
351
|
+
return { success: true, output };
|
|
352
|
+
} catch (e: unknown) {
|
|
353
|
+
return { success: false, output: e instanceof Error ? e.message : String(e) };
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function gitPull(
|
|
358
|
+
cwd: string,
|
|
359
|
+
): { success: boolean; output: string } {
|
|
360
|
+
try {
|
|
361
|
+
const output = git("pull", cwd);
|
|
362
|
+
return { success: true, output };
|
|
363
|
+
} catch (e: unknown) {
|
|
364
|
+
return { success: false, output: e instanceof Error ? e.message : String(e) };
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
export function checkoutBranch(cwd: string, branchName: string): void {
|
|
370
|
+
git(`checkout ${branchName}`, cwd);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function getBranchStatus(
|
|
374
|
+
repoRoot: string,
|
|
375
|
+
branchName: string,
|
|
376
|
+
): { ahead: number; behind: number } {
|
|
377
|
+
const raw = gitSafe(
|
|
378
|
+
`rev-list --left-right --count origin/${branchName}...${branchName}`,
|
|
379
|
+
repoRoot,
|
|
380
|
+
);
|
|
381
|
+
if (!raw) return { ahead: 0, behind: 0 };
|
|
382
|
+
const [behind, ahead] = raw.split(/\s+/).map(Number);
|
|
383
|
+
return { ahead: ahead || 0, behind: behind || 0 };
|
|
384
|
+
}
|