teleportation-cli 1.0.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/.claude/hooks/config-loader.mjs +93 -0
- package/.claude/hooks/heartbeat.mjs +331 -0
- package/.claude/hooks/notification.mjs +35 -0
- package/.claude/hooks/permission_request.mjs +307 -0
- package/.claude/hooks/post_tool_use.mjs +137 -0
- package/.claude/hooks/pre_tool_use.mjs +451 -0
- package/.claude/hooks/session-register.mjs +274 -0
- package/.claude/hooks/session_end.mjs +256 -0
- package/.claude/hooks/session_start.mjs +308 -0
- package/.claude/hooks/stop.mjs +277 -0
- package/.claude/hooks/user_prompt_submit.mjs +91 -0
- package/LICENSE +21 -0
- package/README.md +243 -0
- package/lib/auth/api-key.js +110 -0
- package/lib/auth/credentials.js +341 -0
- package/lib/backup/manager.js +461 -0
- package/lib/cli/daemon-commands.js +299 -0
- package/lib/cli/index.js +303 -0
- package/lib/cli/session-commands.js +294 -0
- package/lib/cli/snapshot-commands.js +223 -0
- package/lib/cli/worktree-commands.js +291 -0
- package/lib/config/manager.js +306 -0
- package/lib/daemon/lifecycle.js +336 -0
- package/lib/daemon/pid-manager.js +160 -0
- package/lib/daemon/teleportation-daemon.js +2009 -0
- package/lib/handoff/config.js +102 -0
- package/lib/handoff/example.js +152 -0
- package/lib/handoff/git-handoff.js +351 -0
- package/lib/handoff/handoff.js +277 -0
- package/lib/handoff/index.js +25 -0
- package/lib/handoff/session-state.js +238 -0
- package/lib/install/installer.js +555 -0
- package/lib/machine-coders/claude-code-adapter.js +329 -0
- package/lib/machine-coders/example.js +239 -0
- package/lib/machine-coders/gemini-cli-adapter.js +406 -0
- package/lib/machine-coders/index.js +103 -0
- package/lib/machine-coders/interface.js +168 -0
- package/lib/router/classifier.js +251 -0
- package/lib/router/example.js +92 -0
- package/lib/router/index.js +69 -0
- package/lib/router/mech-llms-client.js +277 -0
- package/lib/router/models.js +188 -0
- package/lib/router/router.js +382 -0
- package/lib/session/cleanup.js +100 -0
- package/lib/session/metadata.js +258 -0
- package/lib/session/mute-checker.js +114 -0
- package/lib/session-registry/manager.js +302 -0
- package/lib/snapshot/manager.js +390 -0
- package/lib/utils/errors.js +166 -0
- package/lib/utils/logger.js +148 -0
- package/lib/utils/retry.js +155 -0
- package/lib/worktree/manager.js +301 -0
- package/package.json +66 -0
- package/teleportation-cli.cjs +2987 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handoff Configuration
|
|
3
|
+
*
|
|
4
|
+
* Default settings and config loading for cloud handoff.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync, existsSync } from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import { homedir } from 'os';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Default handoff configuration
|
|
13
|
+
*/
|
|
14
|
+
export const DEFAULT_HANDOFF_CONFIG = {
|
|
15
|
+
// Whether auto-handoff is enabled
|
|
16
|
+
enabled: true,
|
|
17
|
+
|
|
18
|
+
// What triggers auto-handoff: "away" | "idle" | "manual"
|
|
19
|
+
// - "away": When approval times out (is_away: true)
|
|
20
|
+
// - "idle": After idle_timeout_minutes of no activity
|
|
21
|
+
// - "manual": Only via explicit `teleport handoff` command
|
|
22
|
+
trigger: 'away',
|
|
23
|
+
|
|
24
|
+
// Minutes of inactivity before auto-handoff (for "idle" trigger)
|
|
25
|
+
idle_timeout_minutes: 5,
|
|
26
|
+
|
|
27
|
+
// Auto-commit uncommitted changes before handoff
|
|
28
|
+
commit_uncommitted: true,
|
|
29
|
+
|
|
30
|
+
// Send push notification on handoff
|
|
31
|
+
notify_on_handoff: true,
|
|
32
|
+
|
|
33
|
+
// Cloud provider: "daytona" | "gitpod" | "codespaces" | "devpod"
|
|
34
|
+
cloud_provider: 'daytona',
|
|
35
|
+
|
|
36
|
+
// Branch prefix for WIP commits
|
|
37
|
+
wip_branch_prefix: 'teleport/wip-',
|
|
38
|
+
|
|
39
|
+
// Auto-sync when returning to local
|
|
40
|
+
auto_sync_on_return: false, // Prompt by default
|
|
41
|
+
|
|
42
|
+
// How often cloud should auto-commit (minutes, 0 = disabled)
|
|
43
|
+
cloud_auto_commit_interval: 5,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Load handoff config from various sources
|
|
48
|
+
* Priority: env vars > project config > user config > defaults
|
|
49
|
+
*
|
|
50
|
+
* @param {string} [projectPath] - Project directory path
|
|
51
|
+
* @returns {Object} Merged configuration
|
|
52
|
+
*/
|
|
53
|
+
export function getHandoffConfig(projectPath = process.cwd()) {
|
|
54
|
+
let config = { ...DEFAULT_HANDOFF_CONFIG };
|
|
55
|
+
|
|
56
|
+
// 1. Load user-level config (~/.teleportation/config.json)
|
|
57
|
+
const userConfigPath = join(homedir(), '.teleportation', 'config.json');
|
|
58
|
+
if (existsSync(userConfigPath)) {
|
|
59
|
+
try {
|
|
60
|
+
const userConfig = JSON.parse(readFileSync(userConfigPath, 'utf-8'));
|
|
61
|
+
if (userConfig.auto_handoff) {
|
|
62
|
+
config = { ...config, ...userConfig.auto_handoff };
|
|
63
|
+
}
|
|
64
|
+
} catch (e) {
|
|
65
|
+
// Ignore parse errors
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 2. Load project-level config (.teleportation/config.json)
|
|
70
|
+
const projectConfigPath = join(projectPath, '.teleportation', 'config.json');
|
|
71
|
+
if (existsSync(projectConfigPath)) {
|
|
72
|
+
try {
|
|
73
|
+
const projectConfig = JSON.parse(readFileSync(projectConfigPath, 'utf-8'));
|
|
74
|
+
if (projectConfig.auto_handoff) {
|
|
75
|
+
config = { ...config, ...projectConfig.auto_handoff };
|
|
76
|
+
}
|
|
77
|
+
} catch (e) {
|
|
78
|
+
// Ignore parse errors
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 3. Override with environment variables
|
|
83
|
+
if (process.env.TELEPORT_HANDOFF_ENABLED !== undefined) {
|
|
84
|
+
config.enabled = process.env.TELEPORT_HANDOFF_ENABLED === 'true';
|
|
85
|
+
}
|
|
86
|
+
if (process.env.TELEPORT_HANDOFF_TRIGGER) {
|
|
87
|
+
config.trigger = process.env.TELEPORT_HANDOFF_TRIGGER;
|
|
88
|
+
}
|
|
89
|
+
if (process.env.TELEPORT_CLOUD_PROVIDER) {
|
|
90
|
+
config.cloud_provider = process.env.TELEPORT_CLOUD_PROVIDER;
|
|
91
|
+
}
|
|
92
|
+
if (process.env.TELEPORT_IDLE_TIMEOUT) {
|
|
93
|
+
config.idle_timeout_minutes = parseInt(process.env.TELEPORT_IDLE_TIMEOUT, 10);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return config;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export default {
|
|
100
|
+
DEFAULT_HANDOFF_CONFIG,
|
|
101
|
+
getHandoffConfig,
|
|
102
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Handoff Module Example / Test Script
|
|
4
|
+
*
|
|
5
|
+
* Demonstrates the handoff and sync operations.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* node lib/handoff/example.js status
|
|
9
|
+
* node lib/handoff/example.js handoff [message]
|
|
10
|
+
* node lib/handoff/example.js sync
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { handoff, sync, getHandoffStatus } from './handoff.js';
|
|
14
|
+
import { getHandoffConfig } from './config.js';
|
|
15
|
+
|
|
16
|
+
const projectPath = process.cwd();
|
|
17
|
+
const sessionId = process.env.TELEPORT_SESSION_ID || `test-${Date.now()}`;
|
|
18
|
+
|
|
19
|
+
async function showStatus() {
|
|
20
|
+
console.log('=== Handoff Status ===\n');
|
|
21
|
+
|
|
22
|
+
const config = getHandoffConfig(projectPath);
|
|
23
|
+
console.log('Configuration:');
|
|
24
|
+
console.log(` Enabled: ${config.enabled}`);
|
|
25
|
+
console.log(` Trigger: ${config.trigger}`);
|
|
26
|
+
console.log(` Cloud Provider: ${config.cloud_provider}`);
|
|
27
|
+
console.log(` WIP Branch Prefix: ${config.wip_branch_prefix}`);
|
|
28
|
+
console.log('');
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const status = await getHandoffStatus({ projectPath, sessionId });
|
|
32
|
+
|
|
33
|
+
console.log('Git Status:');
|
|
34
|
+
console.log(` Current Branch: ${status.currentBranch}`);
|
|
35
|
+
console.log(` WIP Branch: ${status.wipBranch}`);
|
|
36
|
+
console.log(` Local Commit: ${status.localCommit?.slice(0, 7)}`);
|
|
37
|
+
console.log(` Remote Commit: ${status.remoteCommit?.slice(0, 7) || '(none)'}`);
|
|
38
|
+
console.log(` Uncommitted Changes: ${status.hasUncommittedChanges ? 'Yes' : 'No'}`);
|
|
39
|
+
console.log(` Remote Changes: ${status.hasRemoteChanges ? 'Yes' : 'No'}`);
|
|
40
|
+
console.log('');
|
|
41
|
+
|
|
42
|
+
console.log('Session State:');
|
|
43
|
+
console.log(` Location: ${status.location}`);
|
|
44
|
+
console.log(` Session ID: ${status.sessionId}`);
|
|
45
|
+
if (status.lastHandoff) {
|
|
46
|
+
console.log(` Last Handoff: ${status.lastHandoff.type} at ${status.lastHandoff.timestamp}`);
|
|
47
|
+
}
|
|
48
|
+
if (status.cloudContainer) {
|
|
49
|
+
console.log(` Cloud Container: ${status.cloudContainer.id}`);
|
|
50
|
+
}
|
|
51
|
+
console.log('');
|
|
52
|
+
|
|
53
|
+
if (status.diff) {
|
|
54
|
+
console.log('Diff Summary:');
|
|
55
|
+
console.log(` Ahead: ${status.diff.ahead} commits`);
|
|
56
|
+
console.log(` Behind: ${status.diff.behind} commits`);
|
|
57
|
+
if (status.diff.files.length > 0) {
|
|
58
|
+
console.log(` Files: ${status.diff.files.slice(0, 5).join(', ')}${status.diff.files.length > 5 ? '...' : ''}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch (error) {
|
|
62
|
+
console.error(`Error: ${error.message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function doHandoff(message) {
|
|
67
|
+
console.log('=== Performing Handoff ===\n');
|
|
68
|
+
console.log(`Project: ${projectPath}`);
|
|
69
|
+
console.log(`Session: ${sessionId}`);
|
|
70
|
+
console.log(`Message: ${message || '(none)'}`);
|
|
71
|
+
console.log('');
|
|
72
|
+
|
|
73
|
+
const result = await handoff({
|
|
74
|
+
projectPath,
|
|
75
|
+
sessionId,
|
|
76
|
+
message,
|
|
77
|
+
onProgress: ({ step, message }) => {
|
|
78
|
+
console.log(`[${step}] ${message}`);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
console.log('');
|
|
83
|
+
if (result.success) {
|
|
84
|
+
console.log('✓ Handoff successful!');
|
|
85
|
+
console.log(` Branch: ${result.branch}`);
|
|
86
|
+
console.log(` Commit: ${result.commit?.slice(0, 7)}`);
|
|
87
|
+
if (result.files?.length > 0) {
|
|
88
|
+
console.log(` Files: ${result.files.length}`);
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
console.log(`✗ Handoff failed: ${result.error}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function doSync() {
|
|
96
|
+
console.log('=== Syncing from Cloud ===\n');
|
|
97
|
+
console.log(`Project: ${projectPath}`);
|
|
98
|
+
console.log(`Session: ${sessionId}`);
|
|
99
|
+
console.log('');
|
|
100
|
+
|
|
101
|
+
const result = await sync({
|
|
102
|
+
projectPath,
|
|
103
|
+
sessionId,
|
|
104
|
+
strategy: 'merge',
|
|
105
|
+
onProgress: ({ step, message }) => {
|
|
106
|
+
console.log(`[${step}] ${message}`);
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
console.log('');
|
|
111
|
+
if (result.success) {
|
|
112
|
+
console.log('✓ Sync successful!');
|
|
113
|
+
console.log(` Commit: ${result.commit?.slice(0, 7)}`);
|
|
114
|
+
if (result.files?.length > 0) {
|
|
115
|
+
console.log(` Files synced: ${result.files.length}`);
|
|
116
|
+
result.files.slice(0, 5).forEach(f => console.log(` - ${f}`));
|
|
117
|
+
if (result.files.length > 5) {
|
|
118
|
+
console.log(` ... and ${result.files.length - 5} more`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (result.message) {
|
|
122
|
+
console.log(` Note: ${result.message}`);
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
console.log(`✗ Sync failed: ${result.error}`);
|
|
126
|
+
if (result.conflicts?.length > 0) {
|
|
127
|
+
console.log(' Conflicts:');
|
|
128
|
+
result.conflicts.forEach(f => console.log(` - ${f}`));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Main
|
|
134
|
+
const command = process.argv[2] || 'status';
|
|
135
|
+
const arg = process.argv[3];
|
|
136
|
+
|
|
137
|
+
switch (command) {
|
|
138
|
+
case 'status':
|
|
139
|
+
await showStatus();
|
|
140
|
+
break;
|
|
141
|
+
case 'handoff':
|
|
142
|
+
await doHandoff(arg);
|
|
143
|
+
break;
|
|
144
|
+
case 'sync':
|
|
145
|
+
await doSync();
|
|
146
|
+
break;
|
|
147
|
+
default:
|
|
148
|
+
console.log('Usage:');
|
|
149
|
+
console.log(' node lib/handoff/example.js status');
|
|
150
|
+
console.log(' node lib/handoff/example.js handoff [message]');
|
|
151
|
+
console.log(' node lib/handoff/example.js sync');
|
|
152
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git Handoff Operations
|
|
3
|
+
*
|
|
4
|
+
* Handles git operations for seamless local ↔ cloud handoff:
|
|
5
|
+
* - Auto-commit WIP changes
|
|
6
|
+
* - Push to teleport/wip-<session> branch
|
|
7
|
+
* - Pull/sync from cloud
|
|
8
|
+
* - Conflict detection
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execSync, exec } from 'child_process';
|
|
12
|
+
import { promisify } from 'util';
|
|
13
|
+
import { writeFileSync, unlinkSync } from 'fs';
|
|
14
|
+
import { tmpdir } from 'os';
|
|
15
|
+
import { join } from 'path';
|
|
16
|
+
|
|
17
|
+
const execAsync = promisify(exec);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* GitHandoff class for managing git operations during handoff
|
|
21
|
+
*/
|
|
22
|
+
export class GitHandoff {
|
|
23
|
+
/**
|
|
24
|
+
* @param {Object} options
|
|
25
|
+
* @param {string} options.repoPath - Path to the git repository
|
|
26
|
+
* @param {string} options.sessionId - Teleportation session ID
|
|
27
|
+
* @param {string} [options.branchPrefix] - Prefix for WIP branches
|
|
28
|
+
* @param {boolean} [options.verbose] - Enable verbose logging
|
|
29
|
+
*/
|
|
30
|
+
constructor(options) {
|
|
31
|
+
this.repoPath = options.repoPath;
|
|
32
|
+
this.sessionId = options.sessionId;
|
|
33
|
+
this.branchPrefix = options.branchPrefix || 'teleport/wip-';
|
|
34
|
+
this.verbose = options.verbose || false;
|
|
35
|
+
|
|
36
|
+
this.wipBranch = `${this.branchPrefix}${this.sessionId}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Execute a git command in the repo
|
|
41
|
+
* @private
|
|
42
|
+
*/
|
|
43
|
+
async git(command, options = {}) {
|
|
44
|
+
const fullCommand = `git ${command}`;
|
|
45
|
+
if (this.verbose) {
|
|
46
|
+
console.log(`[git-handoff] ${fullCommand}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const { stdout, stderr } = await execAsync(fullCommand, {
|
|
51
|
+
cwd: this.repoPath,
|
|
52
|
+
...options,
|
|
53
|
+
});
|
|
54
|
+
return stdout.trim();
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (this.verbose) {
|
|
57
|
+
console.error(`[git-handoff] Error: ${error.message}`);
|
|
58
|
+
}
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Check if this is a valid git repository
|
|
65
|
+
* @returns {Promise<boolean>}
|
|
66
|
+
*/
|
|
67
|
+
async isGitRepo() {
|
|
68
|
+
try {
|
|
69
|
+
await this.git('rev-parse --is-inside-work-tree');
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Check if the repo has uncommitted changes
|
|
78
|
+
* @returns {Promise<boolean>}
|
|
79
|
+
*/
|
|
80
|
+
async hasUncommittedChanges() {
|
|
81
|
+
const status = await this.git('status --porcelain');
|
|
82
|
+
return status.length > 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Alias for hasUncommittedChanges
|
|
87
|
+
* @returns {Promise<boolean>}
|
|
88
|
+
*/
|
|
89
|
+
async hasChanges() {
|
|
90
|
+
return this.hasUncommittedChanges();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get list of changed files
|
|
95
|
+
* @returns {Promise<string[]>}
|
|
96
|
+
*/
|
|
97
|
+
async getChangedFiles() {
|
|
98
|
+
const status = await this.git('status --porcelain');
|
|
99
|
+
if (!status) return [];
|
|
100
|
+
|
|
101
|
+
return status.split('\n').map(line => {
|
|
102
|
+
// Format: "XY filename" or "XY filename -> newname"
|
|
103
|
+
const match = line.match(/^..\s+(.+?)(?:\s+->\s+.+)?$/);
|
|
104
|
+
return match ? match[1] : line.slice(3);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Get the current branch name
|
|
110
|
+
* @returns {Promise<string>}
|
|
111
|
+
*/
|
|
112
|
+
async getCurrentBranch() {
|
|
113
|
+
return this.git('rev-parse --abbrev-ref HEAD');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get the current commit hash
|
|
118
|
+
* @returns {Promise<string>}
|
|
119
|
+
*/
|
|
120
|
+
async getCurrentCommit() {
|
|
121
|
+
return this.git('rev-parse HEAD');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get the remote URL for origin
|
|
126
|
+
* @returns {Promise<string|null>}
|
|
127
|
+
*/
|
|
128
|
+
async getRemoteUrl() {
|
|
129
|
+
try {
|
|
130
|
+
return await this.git('remote get-url origin');
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Check if a remote branch exists
|
|
138
|
+
* @param {string} branch - Branch name
|
|
139
|
+
* @returns {Promise<boolean>}
|
|
140
|
+
*/
|
|
141
|
+
async remoteBranchExists(branch) {
|
|
142
|
+
try {
|
|
143
|
+
await this.git(`ls-remote --exit-code origin ${branch}`);
|
|
144
|
+
return true;
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Commit all changes with a WIP message
|
|
152
|
+
* @param {string} [message] - Optional message to include
|
|
153
|
+
* @returns {Promise<{commit: string, files: string[]}>}
|
|
154
|
+
*/
|
|
155
|
+
async commitWip(message = '') {
|
|
156
|
+
const files = await this.getChangedFiles();
|
|
157
|
+
if (files.length === 0) {
|
|
158
|
+
return { commit: await this.getCurrentCommit(), files: [] };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Stage all changes
|
|
162
|
+
await this.git('add -A');
|
|
163
|
+
|
|
164
|
+
// Build commit message
|
|
165
|
+
const timestamp = new Date().toISOString();
|
|
166
|
+
const commitMessage = [
|
|
167
|
+
`[teleport] WIP handoff${message ? `: ${message}` : ''}`,
|
|
168
|
+
'',
|
|
169
|
+
`Session: ${this.sessionId}`,
|
|
170
|
+
`Timestamp: ${timestamp}`,
|
|
171
|
+
`Source: local`,
|
|
172
|
+
'',
|
|
173
|
+
'Files changed:',
|
|
174
|
+
...files.map(f => `- ${f}`),
|
|
175
|
+
].join('\n');
|
|
176
|
+
|
|
177
|
+
// Commit using temp file to avoid shell injection
|
|
178
|
+
// Using -F flag prevents any shell metacharacter interpretation
|
|
179
|
+
const tempFile = join(tmpdir(), `teleport-commit-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`);
|
|
180
|
+
try {
|
|
181
|
+
writeFileSync(tempFile, commitMessage, { mode: 0o600 });
|
|
182
|
+
await this.git(`commit -F "${tempFile}"`);
|
|
183
|
+
} finally {
|
|
184
|
+
try { unlinkSync(tempFile); } catch { /* ignore cleanup errors */ }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const commit = await this.getCurrentCommit();
|
|
188
|
+
return { commit, files };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Push current branch to the WIP branch on remote
|
|
193
|
+
* @param {boolean} [force=true] - Force push (default true for WIP branches)
|
|
194
|
+
* @returns {Promise<{branch: string, commit: string}>}
|
|
195
|
+
*/
|
|
196
|
+
async pushToWipBranch(force = true) {
|
|
197
|
+
const commit = await this.getCurrentCommit();
|
|
198
|
+
const forceFlag = force ? '--force' : '';
|
|
199
|
+
|
|
200
|
+
await this.git(`push origin HEAD:${this.wipBranch} ${forceFlag}`);
|
|
201
|
+
|
|
202
|
+
return { branch: this.wipBranch, commit };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Fetch the WIP branch from remote
|
|
207
|
+
* @returns {Promise<boolean>} - True if branch exists and was fetched
|
|
208
|
+
*/
|
|
209
|
+
async fetchWipBranch() {
|
|
210
|
+
try {
|
|
211
|
+
await this.git(`fetch origin ${this.wipBranch}`);
|
|
212
|
+
return true;
|
|
213
|
+
} catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Check if there are changes on the remote WIP branch
|
|
220
|
+
* @returns {Promise<{hasChanges: boolean, localCommit: string, remoteCommit: string|null}>}
|
|
221
|
+
*/
|
|
222
|
+
async checkRemoteChanges() {
|
|
223
|
+
const localCommit = await this.getCurrentCommit();
|
|
224
|
+
|
|
225
|
+
const fetched = await this.fetchWipBranch();
|
|
226
|
+
if (!fetched) {
|
|
227
|
+
return { hasChanges: false, localCommit, remoteCommit: null };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
const remoteCommit = await this.git(`rev-parse origin/${this.wipBranch}`);
|
|
232
|
+
return {
|
|
233
|
+
hasChanges: localCommit !== remoteCommit,
|
|
234
|
+
localCommit,
|
|
235
|
+
remoteCommit,
|
|
236
|
+
};
|
|
237
|
+
} catch {
|
|
238
|
+
return { hasChanges: false, localCommit, remoteCommit: null };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Sync changes from the remote WIP branch
|
|
244
|
+
* @param {Object} options
|
|
245
|
+
* @param {string} [options.strategy='merge'] - 'merge' or 'rebase'
|
|
246
|
+
* @param {boolean} [options.stashLocal=true] - Stash local changes first
|
|
247
|
+
* @returns {Promise<{success: boolean, commit: string, conflicts: string[]}>}
|
|
248
|
+
*/
|
|
249
|
+
async syncFromRemote(options = {}) {
|
|
250
|
+
const { strategy = 'merge', stashLocal = true } = options;
|
|
251
|
+
|
|
252
|
+
// Stash local changes if any
|
|
253
|
+
let stashed = false;
|
|
254
|
+
if (stashLocal && await this.hasUncommittedChanges()) {
|
|
255
|
+
await this.git('stash push -m "teleport-sync-stash"');
|
|
256
|
+
stashed = true;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
// Fetch latest
|
|
261
|
+
await this.fetchWipBranch();
|
|
262
|
+
|
|
263
|
+
// Merge or rebase
|
|
264
|
+
if (strategy === 'rebase') {
|
|
265
|
+
await this.git(`rebase origin/${this.wipBranch}`);
|
|
266
|
+
} else {
|
|
267
|
+
await this.git(`merge origin/${this.wipBranch} --no-edit`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Pop stash if we stashed
|
|
271
|
+
if (stashed) {
|
|
272
|
+
try {
|
|
273
|
+
await this.git('stash pop');
|
|
274
|
+
} catch (e) {
|
|
275
|
+
// Stash pop conflict - leave in stash
|
|
276
|
+
console.warn('[git-handoff] Stash pop had conflicts, changes left in stash');
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
success: true,
|
|
282
|
+
commit: await this.getCurrentCommit(),
|
|
283
|
+
conflicts: [],
|
|
284
|
+
};
|
|
285
|
+
} catch (error) {
|
|
286
|
+
// Check for merge conflicts
|
|
287
|
+
const status = await this.git('status --porcelain');
|
|
288
|
+
const conflicts = status
|
|
289
|
+
.split('\n')
|
|
290
|
+
.filter(line => line.startsWith('UU') || line.startsWith('AA'))
|
|
291
|
+
.map(line => line.slice(3));
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
success: false,
|
|
295
|
+
commit: await this.getCurrentCommit(),
|
|
296
|
+
conflicts,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Get a summary of the diff between local and remote
|
|
303
|
+
* @returns {Promise<{ahead: number, behind: number, files: string[]}>}
|
|
304
|
+
*/
|
|
305
|
+
async getDiffSummary() {
|
|
306
|
+
await this.fetchWipBranch();
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
const ahead = await this.git(`rev-list --count origin/${this.wipBranch}..HEAD`);
|
|
310
|
+
const behind = await this.git(`rev-list --count HEAD..origin/${this.wipBranch}`);
|
|
311
|
+
|
|
312
|
+
let files = [];
|
|
313
|
+
if (parseInt(behind) > 0) {
|
|
314
|
+
const diff = await this.git(`diff --name-only HEAD..origin/${this.wipBranch}`);
|
|
315
|
+
files = diff ? diff.split('\n') : [];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
ahead: parseInt(ahead),
|
|
320
|
+
behind: parseInt(behind),
|
|
321
|
+
files,
|
|
322
|
+
};
|
|
323
|
+
} catch {
|
|
324
|
+
return { ahead: 0, behind: 0, files: [] };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Clean up the WIP branch (delete from remote)
|
|
330
|
+
* @returns {Promise<boolean>}
|
|
331
|
+
*/
|
|
332
|
+
async cleanupWipBranch() {
|
|
333
|
+
try {
|
|
334
|
+
await this.git(`push origin --delete ${this.wipBranch}`);
|
|
335
|
+
return true;
|
|
336
|
+
} catch {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Create a GitHandoff instance
|
|
344
|
+
* @param {Object} options - Same as GitHandoff constructor
|
|
345
|
+
* @returns {GitHandoff}
|
|
346
|
+
*/
|
|
347
|
+
export function createGitHandoff(options) {
|
|
348
|
+
return new GitHandoff(options);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export default GitHandoff;
|