@tmux-web/ext-git-workflow 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/dist/backend/git.js +214 -0
- package/dist/backend/pane-ready.js +37 -0
- package/dist/backend/routes/commit-push.js +46 -0
- package/dist/backend/routes/handoff.js +85 -0
- package/dist/backend/routes/send-keys.js +31 -0
- package/dist/backend/routes/status.js +10 -0
- package/dist/backend/server.js +25 -0
- package/dist/backend/status-service.js +110 -0
- package/dist/backend/storage.js +32 -0
- package/dist/backend/tmux.js +58 -0
- package/dist/ui/app.js +425 -0
- package/dist/ui/index.html +334 -0
- package/node_modules/@tmux-web/ext-gh-workflow/dist/gh-client.d.ts +24 -0
- package/node_modules/@tmux-web/ext-gh-workflow/dist/gh-client.js +177 -0
- package/node_modules/@tmux-web/ext-gh-workflow/dist/gh-pr.d.ts +20 -0
- package/node_modules/@tmux-web/ext-gh-workflow/dist/gh-pr.js +43 -0
- package/node_modules/@tmux-web/ext-gh-workflow/dist/gh-repo.d.ts +14 -0
- package/node_modules/@tmux-web/ext-gh-workflow/dist/gh-repo.js +58 -0
- package/node_modules/@tmux-web/ext-gh-workflow/dist/index.d.ts +3 -0
- package/node_modules/@tmux-web/ext-gh-workflow/dist/index.js +3 -0
- package/node_modules/@tmux-web/ext-gh-workflow/package.json +38 -0
- package/node_modules/@tmux-web/ext-sdk/dist/bridge.d.ts +31 -0
- package/node_modules/@tmux-web/ext-sdk/dist/bridge.js +103 -0
- package/node_modules/@tmux-web/ext-sdk/dist/index.d.ts +7 -0
- package/node_modules/@tmux-web/ext-sdk/dist/index.js +12 -0
- package/node_modules/@tmux-web/ext-sdk/dist/types.d.ts +20 -0
- package/node_modules/@tmux-web/ext-sdk/dist/types.js +1 -0
- package/node_modules/@tmux-web/ext-sdk/package.json +32 -0
- package/package.json +43 -0
- package/tmux-extension.json +13 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
const GIT_TIMEOUT_MS = 15_000;
|
|
7
|
+
function git(args, cwd) {
|
|
8
|
+
return execFileSync('git', args, {
|
|
9
|
+
cwd,
|
|
10
|
+
encoding: 'utf-8',
|
|
11
|
+
timeout: GIT_TIMEOUT_MS,
|
|
12
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
13
|
+
}).trimEnd();
|
|
14
|
+
}
|
|
15
|
+
export function repoRoot(cwd) {
|
|
16
|
+
try {
|
|
17
|
+
return git(['rev-parse', '--show-toplevel'], cwd);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function worktreeRootDir() {
|
|
24
|
+
return path.join(os.homedir(), '.worktrees');
|
|
25
|
+
}
|
|
26
|
+
export function classifyKind(cwd) {
|
|
27
|
+
const root = worktreeRootDir() + path.sep;
|
|
28
|
+
return cwd.startsWith(root) ? 'worktree' : 'local';
|
|
29
|
+
}
|
|
30
|
+
export function buildWorktreePath(org, repo, id) {
|
|
31
|
+
return path.join(worktreeRootDir(), org, repo, id);
|
|
32
|
+
}
|
|
33
|
+
export function generateWorktreeId() {
|
|
34
|
+
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
35
|
+
const bytes = randomBytes(8);
|
|
36
|
+
let out = '';
|
|
37
|
+
for (let i = 0; i < 8; i++) {
|
|
38
|
+
out += chars[bytes[i] % chars.length];
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
export function resolveMainRepoPath(repoRootPath) {
|
|
43
|
+
try {
|
|
44
|
+
const out = git(['worktree', 'list', '--porcelain'], repoRootPath);
|
|
45
|
+
for (const line of out.split('\n')) {
|
|
46
|
+
if (line.startsWith('worktree ')) {
|
|
47
|
+
return line.slice('worktree '.length).trim();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// fall through
|
|
53
|
+
}
|
|
54
|
+
return repoRootPath;
|
|
55
|
+
}
|
|
56
|
+
export function parseCheckedOutBranches(worktreeListPorcelain) {
|
|
57
|
+
const branches = new Set();
|
|
58
|
+
for (const line of worktreeListPorcelain.split('\n')) {
|
|
59
|
+
if (!line.startsWith('branch '))
|
|
60
|
+
continue;
|
|
61
|
+
const ref = line.slice('branch '.length).trim();
|
|
62
|
+
if (ref.startsWith('refs/heads/')) {
|
|
63
|
+
branches.add(ref.slice('refs/heads/'.length));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return branches;
|
|
67
|
+
}
|
|
68
|
+
export function isBranchCheckedOut(repoRootPath, branch) {
|
|
69
|
+
const trimmed = branch.trim();
|
|
70
|
+
if (!trimmed)
|
|
71
|
+
return false;
|
|
72
|
+
try {
|
|
73
|
+
return parseCheckedOutBranches(git(['worktree', 'list', '--porcelain'], repoRootPath)).has(trimmed);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function getGitStatus(repoRootPath) {
|
|
80
|
+
const branch = git(['branch', '--show-current'], repoRootPath) || 'HEAD';
|
|
81
|
+
let added = 0;
|
|
82
|
+
let removed = 0;
|
|
83
|
+
let dirty = false;
|
|
84
|
+
try {
|
|
85
|
+
const porcelain = git(['status', '--porcelain'], repoRootPath);
|
|
86
|
+
if (porcelain) {
|
|
87
|
+
dirty = true;
|
|
88
|
+
for (const line of porcelain.split('\n')) {
|
|
89
|
+
if (!line.trim())
|
|
90
|
+
continue;
|
|
91
|
+
const idx = line.slice(0, 2);
|
|
92
|
+
if (idx[0] !== ' ' && idx[0] !== '?')
|
|
93
|
+
added++;
|
|
94
|
+
if (idx[1] !== ' ' && idx[1] !== '?')
|
|
95
|
+
removed++;
|
|
96
|
+
if (idx === '??')
|
|
97
|
+
added++;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
dirty = false;
|
|
103
|
+
}
|
|
104
|
+
let ahead = 0;
|
|
105
|
+
let behind = 0;
|
|
106
|
+
try {
|
|
107
|
+
const ab = git(['rev-list', '--left-right', '--count', '@{upstream}...HEAD'], repoRootPath);
|
|
108
|
+
const [b, a] = ab.split('\t').map((n) => parseInt(n, 10));
|
|
109
|
+
if (Number.isFinite(a))
|
|
110
|
+
ahead = a;
|
|
111
|
+
if (Number.isFinite(b))
|
|
112
|
+
behind = b;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// no upstream
|
|
116
|
+
}
|
|
117
|
+
return { branch, dirty, changes: { added, removed }, ahead, behind };
|
|
118
|
+
}
|
|
119
|
+
export function listBranchNames(repoRootPath) {
|
|
120
|
+
try {
|
|
121
|
+
const out = git(['branch', '-a', '--format=%(refname:short)'], repoRootPath);
|
|
122
|
+
const seen = new Set();
|
|
123
|
+
const branches = [];
|
|
124
|
+
for (const raw of out.split('\n')) {
|
|
125
|
+
let name = raw.trim();
|
|
126
|
+
if (!name)
|
|
127
|
+
continue;
|
|
128
|
+
if (name.startsWith('origin/'))
|
|
129
|
+
name = name.slice('origin/'.length);
|
|
130
|
+
if (name === 'HEAD' || name.includes('->'))
|
|
131
|
+
continue;
|
|
132
|
+
if (seen.has(name))
|
|
133
|
+
continue;
|
|
134
|
+
seen.add(name);
|
|
135
|
+
branches.push(name);
|
|
136
|
+
}
|
|
137
|
+
branches.sort((a, b) => a.localeCompare(b));
|
|
138
|
+
return branches;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
export function branchExists(repoRootPath, branch) {
|
|
145
|
+
const trimmed = branch.trim();
|
|
146
|
+
if (!trimmed)
|
|
147
|
+
return { exists: false };
|
|
148
|
+
try {
|
|
149
|
+
const local = git(['branch', '--list', trimmed], repoRootPath);
|
|
150
|
+
if (local.trim())
|
|
151
|
+
return { exists: true, source: 'local' };
|
|
152
|
+
}
|
|
153
|
+
catch { /* ignore */ }
|
|
154
|
+
try {
|
|
155
|
+
const remote = git(['ls-remote', '--heads', 'origin', `refs/heads/${trimmed}`], repoRootPath);
|
|
156
|
+
if (remote.trim())
|
|
157
|
+
return { exists: true, source: 'remote' };
|
|
158
|
+
}
|
|
159
|
+
catch { /* ignore */ }
|
|
160
|
+
return { exists: false };
|
|
161
|
+
}
|
|
162
|
+
export function ensureLocalBranch(repoRootPath, branch) {
|
|
163
|
+
try {
|
|
164
|
+
const local = git(['branch', '--list', branch], repoRootPath);
|
|
165
|
+
if (local.trim())
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
execFileSync('git', ['fetch', 'origin', `${branch}:${branch}`], {
|
|
172
|
+
cwd: repoRootPath,
|
|
173
|
+
encoding: 'utf-8',
|
|
174
|
+
timeout: GIT_TIMEOUT_MS,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
export function createWorktree(mainRepoRoot, worktreePath, branch, createBranch, startPoint) {
|
|
178
|
+
mkdirSync(path.dirname(worktreePath), { recursive: true });
|
|
179
|
+
if (createBranch) {
|
|
180
|
+
const args = ['worktree', 'add', '-b', branch, worktreePath];
|
|
181
|
+
if (startPoint)
|
|
182
|
+
args.push(startPoint);
|
|
183
|
+
execFileSync('git', args, {
|
|
184
|
+
cwd: mainRepoRoot,
|
|
185
|
+
encoding: 'utf-8',
|
|
186
|
+
timeout: GIT_TIMEOUT_MS,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
execFileSync('git', ['worktree', 'add', worktreePath, branch], {
|
|
191
|
+
cwd: mainRepoRoot,
|
|
192
|
+
encoding: 'utf-8',
|
|
193
|
+
timeout: GIT_TIMEOUT_MS,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
export function buildHandoffBranchName(sourceBranch, id) {
|
|
198
|
+
return `tmux-web/${sourceBranch}/${id}`;
|
|
199
|
+
}
|
|
200
|
+
export function pickUniqueWorktreePath(org, repo) {
|
|
201
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
202
|
+
const candidate = buildWorktreePath(org, repo, generateWorktreeId());
|
|
203
|
+
if (!existsSync(candidate))
|
|
204
|
+
return candidate;
|
|
205
|
+
}
|
|
206
|
+
throw new Error('Failed to allocate unique worktree directory');
|
|
207
|
+
}
|
|
208
|
+
export function commitAll(repoRootPath, message) {
|
|
209
|
+
git(['add', '-A'], repoRootPath);
|
|
210
|
+
git(['commit', '-m', message], repoRootPath);
|
|
211
|
+
}
|
|
212
|
+
export function push(repoRootPath) {
|
|
213
|
+
git(['push'], repoRootPath);
|
|
214
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const SHELL_COMMANDS = new Set(['bash', 'zsh', 'fish', 'sh', 'dash']);
|
|
2
|
+
function commandBasename(command) {
|
|
3
|
+
const trimmed = command.trim();
|
|
4
|
+
const slash = trimmed.lastIndexOf('/');
|
|
5
|
+
return (slash >= 0 ? trimmed.slice(slash + 1) : trimmed).toLowerCase();
|
|
6
|
+
}
|
|
7
|
+
function hasAgentBusyHints(screen) {
|
|
8
|
+
const lines = screen.split('\n').map((l) => l.replace(/\s+$/, '')).filter((l) => l.trim() !== '');
|
|
9
|
+
const live = lines.slice(-20);
|
|
10
|
+
const liveLower = live.join('\n').toLowerCase();
|
|
11
|
+
if (liveLower.includes('esc to interrupt')
|
|
12
|
+
|| liveLower.includes('ctrl+c to interrupt')
|
|
13
|
+
|| liveLower.includes('ctrl-c to interrupt')) {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
return live.some((l) => /[❯>›]\s*\d+\.\s+\S/.test(l));
|
|
17
|
+
}
|
|
18
|
+
export function isPaneReady(input) {
|
|
19
|
+
if (input.alternateOn)
|
|
20
|
+
return false;
|
|
21
|
+
if (!SHELL_COMMANDS.has(commandBasename(input.paneCommand)))
|
|
22
|
+
return false;
|
|
23
|
+
if (input.paneScreen && hasAgentBusyHints(input.paneScreen))
|
|
24
|
+
return false;
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
export function paneNotReadyReason(input) {
|
|
28
|
+
if (input.alternateOn)
|
|
29
|
+
return 'Pane is in alternate screen mode';
|
|
30
|
+
if (!SHELL_COMMANDS.has(commandBasename(input.paneCommand))) {
|
|
31
|
+
return `Foreground process is not a shell (${input.paneCommand || 'unknown'})`;
|
|
32
|
+
}
|
|
33
|
+
if (input.paneScreen && hasAgentBusyHints(input.paneScreen)) {
|
|
34
|
+
return 'An interactive agent is active in this pane';
|
|
35
|
+
}
|
|
36
|
+
return 'Pane is not ready';
|
|
37
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { commitAll, getGitStatus, push, repoRoot } from '../git.js';
|
|
3
|
+
import { getActivePaneInfo } from '../tmux.js';
|
|
4
|
+
import { fetchSessionStatus } from '../status-service.js';
|
|
5
|
+
export const commitPushRouter = new Hono();
|
|
6
|
+
commitPushRouter.post('/commit-push', async (c) => {
|
|
7
|
+
const body = await c.req.json();
|
|
8
|
+
const session = body.session?.trim();
|
|
9
|
+
const message = body.message?.trim();
|
|
10
|
+
if (!session)
|
|
11
|
+
return c.json({ error: 'session is required' }, 400);
|
|
12
|
+
const pane = getActivePaneInfo(session);
|
|
13
|
+
if (!pane)
|
|
14
|
+
return c.json({ error: 'Could not resolve active tmux pane' }, 404);
|
|
15
|
+
const cwd = pane.panePath;
|
|
16
|
+
if (!cwd)
|
|
17
|
+
return c.json({ error: 'Could not detect pane working directory' }, 400);
|
|
18
|
+
const root = repoRoot(cwd);
|
|
19
|
+
if (!root)
|
|
20
|
+
return c.json({ error: 'Not a git repository' }, 400);
|
|
21
|
+
const before = getGitStatus(root);
|
|
22
|
+
if (!before.dirty && before.ahead === 0) {
|
|
23
|
+
return c.json({ error: 'Nothing to commit or push' }, 400);
|
|
24
|
+
}
|
|
25
|
+
if (before.dirty) {
|
|
26
|
+
if (!message)
|
|
27
|
+
return c.json({ error: 'Commit message is required when there are uncommitted changes' }, 400);
|
|
28
|
+
try {
|
|
29
|
+
commitAll(root, message);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
return c.json({ error: `Commit failed: ${err.message}` }, 500);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const afterCommit = getGitStatus(root);
|
|
36
|
+
if (afterCommit.ahead > 0) {
|
|
37
|
+
try {
|
|
38
|
+
push(root);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
return c.json({ error: `Push failed: ${err.message}` }, 500);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const status = await fetchSessionStatus(session);
|
|
45
|
+
return c.json({ ok: true, status });
|
|
46
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { ghRepoView } from '@tmux-web/ext-gh-workflow';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { branchExists, buildHandoffBranchName, createWorktree, ensureLocalBranch, isBranchCheckedOut, pickUniqueWorktreePath, repoRoot, resolveMainRepoPath, } from '../git.js';
|
|
5
|
+
import { isPaneReady, paneNotReadyReason } from '../pane-ready.js';
|
|
6
|
+
import { capturePaneTail, getActivePaneInfo, sendKeysToPane } from '../tmux.js';
|
|
7
|
+
import { fetchSessionStatus } from '../status-service.js';
|
|
8
|
+
export const handoffRouter = new Hono();
|
|
9
|
+
handoffRouter.post('/handoff', async (c) => {
|
|
10
|
+
const body = await c.req.json();
|
|
11
|
+
const session = body.session?.trim();
|
|
12
|
+
const branch = body.branch?.trim();
|
|
13
|
+
const confirmCreate = body.confirmCreate === true;
|
|
14
|
+
if (!session || !branch) {
|
|
15
|
+
return c.json({ error: 'session and branch are required' }, 400);
|
|
16
|
+
}
|
|
17
|
+
const pane = getActivePaneInfo(session);
|
|
18
|
+
if (!pane)
|
|
19
|
+
return c.json({ error: 'Could not resolve active tmux pane' }, 404);
|
|
20
|
+
const cwd = pane.panePath;
|
|
21
|
+
if (!cwd)
|
|
22
|
+
return c.json({ error: 'Could not detect pane working directory' }, 400);
|
|
23
|
+
const root = repoRoot(cwd);
|
|
24
|
+
if (!root)
|
|
25
|
+
return c.json({ error: 'Not a git repository' }, 400);
|
|
26
|
+
const github = await ghRepoView(cwd);
|
|
27
|
+
if (!github) {
|
|
28
|
+
return c.json({ error: 'Works with GitHub repositories only' }, 400);
|
|
29
|
+
}
|
|
30
|
+
const mainRepoRoot = resolveMainRepoPath(root) ?? root;
|
|
31
|
+
const exists = branchExists(mainRepoRoot, branch);
|
|
32
|
+
if (!exists.exists && !confirmCreate) {
|
|
33
|
+
return c.json({ needsConfirmation: true, branch }, 409);
|
|
34
|
+
}
|
|
35
|
+
let worktreePath;
|
|
36
|
+
try {
|
|
37
|
+
worktreePath = pickUniqueWorktreePath(github.org, github.repo);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
return c.json({ error: String(err.message) }, 500);
|
|
41
|
+
}
|
|
42
|
+
let targetBranch = branch;
|
|
43
|
+
try {
|
|
44
|
+
if (exists.exists && exists.source === 'remote') {
|
|
45
|
+
ensureLocalBranch(mainRepoRoot, branch);
|
|
46
|
+
}
|
|
47
|
+
let createBranch = !exists.exists;
|
|
48
|
+
let startPoint;
|
|
49
|
+
if (exists.exists && isBranchCheckedOut(mainRepoRoot, branch)) {
|
|
50
|
+
targetBranch = buildHandoffBranchName(branch, path.basename(worktreePath));
|
|
51
|
+
createBranch = true;
|
|
52
|
+
startPoint = branch;
|
|
53
|
+
}
|
|
54
|
+
createWorktree(mainRepoRoot, worktreePath, targetBranch, createBranch, startPoint);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
return c.json({ error: `git worktree add failed: ${err.message}` }, 500);
|
|
58
|
+
}
|
|
59
|
+
const screen = capturePaneTail(pane.target, 20);
|
|
60
|
+
const readyInput = {
|
|
61
|
+
alternateOn: pane.alternateOn,
|
|
62
|
+
paneCommand: pane.paneCommand,
|
|
63
|
+
paneScreen: screen,
|
|
64
|
+
};
|
|
65
|
+
const paneReady = isPaneReady(readyInput);
|
|
66
|
+
let cdApplied = false;
|
|
67
|
+
let cdSkippedReason;
|
|
68
|
+
if (paneReady) {
|
|
69
|
+
sendKeysToPane(pane.target, `cd '${worktreePath.replace(/'/g, "'\\''")}'`);
|
|
70
|
+
cdApplied = true;
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
cdSkippedReason = paneNotReadyReason(readyInput);
|
|
74
|
+
}
|
|
75
|
+
const status = await fetchSessionStatus(session);
|
|
76
|
+
return c.json({
|
|
77
|
+
worktreePath,
|
|
78
|
+
mainRepoPath: mainRepoRoot,
|
|
79
|
+
requestedBranch: branch,
|
|
80
|
+
branch: targetBranch,
|
|
81
|
+
cdApplied,
|
|
82
|
+
cdSkippedReason,
|
|
83
|
+
status,
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { getActivePaneInfo, sendKeysToPane, capturePaneTail } from '../tmux.js';
|
|
3
|
+
import { isPaneReady } from '../pane-ready.js';
|
|
4
|
+
export const sendKeysRouter = new Hono();
|
|
5
|
+
sendKeysRouter.post('/send-keys', async (c) => {
|
|
6
|
+
const body = await c.req.json();
|
|
7
|
+
const session = body.session?.trim();
|
|
8
|
+
const text = body.text?.trim();
|
|
9
|
+
if (!session || !text) {
|
|
10
|
+
return c.json({ error: 'session and text are required' }, 400);
|
|
11
|
+
}
|
|
12
|
+
const pane = getActivePaneInfo(session);
|
|
13
|
+
if (!pane)
|
|
14
|
+
return c.json({ error: 'Could not resolve active tmux pane' }, 404);
|
|
15
|
+
const screen = capturePaneTail(pane.target, 20);
|
|
16
|
+
const ready = isPaneReady({
|
|
17
|
+
alternateOn: pane.alternateOn,
|
|
18
|
+
paneCommand: pane.paneCommand,
|
|
19
|
+
paneScreen: screen,
|
|
20
|
+
});
|
|
21
|
+
if (!ready) {
|
|
22
|
+
return c.json({ error: 'Pane is not ready to receive input' }, 409);
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
sendKeysToPane(pane.target, text);
|
|
26
|
+
return c.json({ ok: true });
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
return c.json({ error: String(err.message) }, 500);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { fetchSessionStatus } from '../status-service.js';
|
|
3
|
+
export const statusRouter = new Hono();
|
|
4
|
+
statusRouter.get('/status', async (c) => {
|
|
5
|
+
const session = c.req.query('session');
|
|
6
|
+
if (!session)
|
|
7
|
+
return c.json({ error: 'session is required' }, 400);
|
|
8
|
+
const result = await fetchSessionStatus(session);
|
|
9
|
+
return c.json(result);
|
|
10
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { serve, createAdaptorServer } from '@hono/node-server';
|
|
3
|
+
import { unlinkSync } from 'node:fs';
|
|
4
|
+
import { statusRouter } from './routes/status.js';
|
|
5
|
+
import { handoffRouter } from './routes/handoff.js';
|
|
6
|
+
import { commitPushRouter } from './routes/commit-push.js';
|
|
7
|
+
import { sendKeysRouter } from './routes/send-keys.js';
|
|
8
|
+
const app = new Hono();
|
|
9
|
+
app.route('/', statusRouter);
|
|
10
|
+
app.route('/', handoffRouter);
|
|
11
|
+
app.route('/', commitPushRouter);
|
|
12
|
+
app.route('/', sendKeysRouter);
|
|
13
|
+
const sockPath = process.env.EXT_SOCKET;
|
|
14
|
+
if (sockPath) {
|
|
15
|
+
try {
|
|
16
|
+
unlinkSync(sockPath);
|
|
17
|
+
}
|
|
18
|
+
catch { }
|
|
19
|
+
const server = createAdaptorServer({ fetch: app.fetch });
|
|
20
|
+
server.listen(sockPath, () => console.log(`[git-workflow ext] listening on ${sockPath}`));
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
const port = parseInt(process.env.EXT_PORT ?? '4101', 10);
|
|
24
|
+
serve({ fetch: app.fetch, port }, () => console.log(`[git-workflow ext] running on :${port}`));
|
|
25
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { ghRepoView, fetchPrForBranch, fetchPrChecks, fetchBranchHead } from '@tmux-web/ext-gh-workflow';
|
|
2
|
+
import { capturePaneTail, getActivePaneInfo } from './tmux.js';
|
|
3
|
+
import { isPaneReady } from './pane-ready.js';
|
|
4
|
+
import { classifyKind, getGitStatus, listBranchNames, repoRoot, resolveMainRepoPath, } from './git.js';
|
|
5
|
+
import { cacheKey, getCachedPane, setCachedPane } from './storage.js';
|
|
6
|
+
async function buildPaneCache(session, pane) {
|
|
7
|
+
const panePath = pane.panePath;
|
|
8
|
+
if (!panePath) {
|
|
9
|
+
return { isRepo: false, isGithub: false, message: 'Could not detect pane working directory' };
|
|
10
|
+
}
|
|
11
|
+
const root = repoRoot(panePath);
|
|
12
|
+
if (!root) {
|
|
13
|
+
return {
|
|
14
|
+
isRepo: false,
|
|
15
|
+
isGithub: false,
|
|
16
|
+
paneId: pane.paneId,
|
|
17
|
+
windowIndex: pane.windowIndex,
|
|
18
|
+
panePath,
|
|
19
|
+
message: 'Not a git repository',
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
const github = await ghRepoView(panePath);
|
|
23
|
+
if (!github) {
|
|
24
|
+
return {
|
|
25
|
+
isRepo: true,
|
|
26
|
+
isGithub: false,
|
|
27
|
+
paneId: pane.paneId,
|
|
28
|
+
windowIndex: pane.windowIndex,
|
|
29
|
+
panePath,
|
|
30
|
+
message: 'Works with GitHub repositories only',
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const screen = capturePaneTail(pane.target, 20);
|
|
34
|
+
const paneReady = isPaneReady({
|
|
35
|
+
alternateOn: pane.alternateOn,
|
|
36
|
+
paneCommand: pane.paneCommand,
|
|
37
|
+
paneScreen: screen,
|
|
38
|
+
});
|
|
39
|
+
const kind = classifyKind(panePath);
|
|
40
|
+
const gitStatus = getGitStatus(root);
|
|
41
|
+
const mainRepoPath = kind === 'worktree' ? resolveMainRepoPath(root) : root;
|
|
42
|
+
const branchSource = mainRepoPath ?? root;
|
|
43
|
+
let pr = null;
|
|
44
|
+
let branchChecks = null;
|
|
45
|
+
const branch = gitStatus.branch;
|
|
46
|
+
if (branch !== 'main' && branch !== 'master') {
|
|
47
|
+
try {
|
|
48
|
+
const prBase = await fetchPrForBranch(github.nameWithOwner, branch);
|
|
49
|
+
if (prBase) {
|
|
50
|
+
const checks = await fetchPrChecks(github.nameWithOwner, prBase.headSha);
|
|
51
|
+
pr = { ...prBase, checks };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// PR fetch is best-effort; don't fail the status response
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
try {
|
|
60
|
+
const head = await fetchBranchHead(github.nameWithOwner, branch);
|
|
61
|
+
if (head) {
|
|
62
|
+
const checks = await fetchPrChecks(github.nameWithOwner, head.headSha);
|
|
63
|
+
branchChecks = { branch, headSha: head.headSha, url: head.url, checks };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// branch checks fetch is best-effort; don't fail the status response
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const entry = {
|
|
71
|
+
session,
|
|
72
|
+
paneId: pane.paneId,
|
|
73
|
+
panePath,
|
|
74
|
+
windowIndex: pane.windowIndex,
|
|
75
|
+
branch,
|
|
76
|
+
kind,
|
|
77
|
+
mainRepoPath,
|
|
78
|
+
repoRoot: root,
|
|
79
|
+
github,
|
|
80
|
+
changes: gitStatus.changes,
|
|
81
|
+
dirty: gitStatus.dirty,
|
|
82
|
+
ahead: gitStatus.ahead,
|
|
83
|
+
behind: gitStatus.behind,
|
|
84
|
+
branches: listBranchNames(branchSource),
|
|
85
|
+
paneReady,
|
|
86
|
+
fetchedAt: Date.now(),
|
|
87
|
+
pr,
|
|
88
|
+
branchChecks,
|
|
89
|
+
};
|
|
90
|
+
const key = cacheKey(session, pane.paneId, panePath);
|
|
91
|
+
const prev = await getCachedPane(key);
|
|
92
|
+
await setCachedPane(key, entry);
|
|
93
|
+
return {
|
|
94
|
+
isRepo: true,
|
|
95
|
+
isGithub: true,
|
|
96
|
+
paneId: pane.paneId,
|
|
97
|
+
windowIndex: pane.windowIndex,
|
|
98
|
+
panePath,
|
|
99
|
+
paneReady,
|
|
100
|
+
cached: prev !== null,
|
|
101
|
+
data: entry,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
export async function fetchSessionStatus(session) {
|
|
105
|
+
const pane = getActivePaneInfo(session);
|
|
106
|
+
if (!pane) {
|
|
107
|
+
return { isRepo: false, isGithub: false, message: 'Could not resolve active tmux pane' };
|
|
108
|
+
}
|
|
109
|
+
return buildPaneCache(session, pane);
|
|
110
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
const DATA_ROOT = process.env.TMUX_WEB_DATA_ROOT
|
|
5
|
+
?? path.join(os.homedir(), '.tmux-web');
|
|
6
|
+
const DATA_DIR = process.env.EXT_DATA_DIR
|
|
7
|
+
?? path.join(DATA_ROOT, 'extensions', 'git-workflow');
|
|
8
|
+
const DATA_FILE = path.join(DATA_DIR, 'data.json');
|
|
9
|
+
export function cacheKey(session, paneId, panePath) {
|
|
10
|
+
return `${session}|${paneId}|${panePath}`;
|
|
11
|
+
}
|
|
12
|
+
async function readStore() {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(await readFile(DATA_FILE, 'utf-8'));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return { panes: {} };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
async function saveStore(store) {
|
|
21
|
+
await mkdir(DATA_DIR, { recursive: true });
|
|
22
|
+
await writeFile(DATA_FILE, JSON.stringify(store, null, 2));
|
|
23
|
+
}
|
|
24
|
+
export async function getCachedPane(key) {
|
|
25
|
+
const store = await readStore();
|
|
26
|
+
return store.panes[key] ?? null;
|
|
27
|
+
}
|
|
28
|
+
export async function setCachedPane(key, entry) {
|
|
29
|
+
const store = await readStore();
|
|
30
|
+
store.panes[key] = entry;
|
|
31
|
+
await saveStore(store);
|
|
32
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
const TMUX_TIMEOUT_MS = 3000;
|
|
3
|
+
const PANE_FORMAT = '#{pane_id}\t#{pane_index}\t#{window_index}\t#{pane_current_path}\t#{pane_current_command}\t#{alternate_on}\t#{pane_pid}';
|
|
4
|
+
function tmux(args) {
|
|
5
|
+
return execFileSync('tmux', args, { encoding: 'utf-8', timeout: TMUX_TIMEOUT_MS }).trimEnd();
|
|
6
|
+
}
|
|
7
|
+
function resolvePanePath(reportedPath, pid) {
|
|
8
|
+
if (reportedPath?.trim())
|
|
9
|
+
return reportedPath.trim();
|
|
10
|
+
if (!Number.isFinite(pid) || pid <= 0)
|
|
11
|
+
return '';
|
|
12
|
+
try {
|
|
13
|
+
const out = execFileSync('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { encoding: 'utf-8', timeout: TMUX_TIMEOUT_MS });
|
|
14
|
+
for (const line of out.split('\n')) {
|
|
15
|
+
if (line.startsWith('n'))
|
|
16
|
+
return line.slice(1).trim();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// lsof unavailable or permission denied
|
|
21
|
+
}
|
|
22
|
+
return '';
|
|
23
|
+
}
|
|
24
|
+
export function getActivePaneInfo(session) {
|
|
25
|
+
try {
|
|
26
|
+
const out = tmux(['display-message', '-p', '-t', session, '-F', PANE_FORMAT]);
|
|
27
|
+
const [paneId, paneIndex, windowIndex, panePath, paneCommand, alternateOn, panePid] = out.split('\t');
|
|
28
|
+
if (!paneId)
|
|
29
|
+
return null;
|
|
30
|
+
const winIdx = parseInt(windowIndex, 10);
|
|
31
|
+
const pIdx = parseInt(paneIndex, 10);
|
|
32
|
+
const pid = parseInt(panePid ?? '', 10);
|
|
33
|
+
return {
|
|
34
|
+
paneId,
|
|
35
|
+
paneIndex: Number.isFinite(pIdx) ? pIdx : 0,
|
|
36
|
+
windowIndex: Number.isFinite(winIdx) ? winIdx : 0,
|
|
37
|
+
panePath: resolvePanePath(panePath ?? '', pid),
|
|
38
|
+
paneCommand: paneCommand ?? '',
|
|
39
|
+
alternateOn: alternateOn === '1',
|
|
40
|
+
target: `${session}:${winIdx}.${pIdx}`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function sendKeysToPane(target, text) {
|
|
48
|
+
execFileSync('tmux', ['send-keys', '-t', target, '-l', text], { timeout: 5000 });
|
|
49
|
+
execFileSync('tmux', ['send-keys', '-t', target, 'Enter'], { timeout: 5000 });
|
|
50
|
+
}
|
|
51
|
+
export function capturePaneTail(target, lines) {
|
|
52
|
+
try {
|
|
53
|
+
return execFileSync('tmux', ['capture-pane', '-t', target, '-p', '-S', String(-lines)], { encoding: 'utf-8', timeout: TMUX_TIMEOUT_MS });
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return '';
|
|
57
|
+
}
|
|
58
|
+
}
|