Files
openchamber/packages/web/server/lib/git/service.js
T
Bohdan Triapitsyn 285c3bcaae Migrate desktop shell from Tauri to Electron (#964)
* feat(electron): scaffold Electron desktop package

Main + preload + ssh manager, packaging scripts, icons, root build/lint/type-check wiring.

* feat(ui): add Electron runtime detection and desktopNative facade

isElectronShell via window.__OPENCHAMBER_ELECTRON__, isDesktopShell now covers both. desktopNative wraps window/title/theme calls so UI avoids direct Tauri imports. revealDesktopPath added.

* refactor(ui): route window/title/theme/export through desktopNative

SessionSidebar, MultiRunLauncher, useWindowTitle, ThemeSystemContext, exportSession drop direct @tauri-apps imports.

* refactor(ui): treat all desktop shells uniformly

device.ts switches Tauri-only checks to isDesktopShell. Header OpenInApp button uses actionDirectory so it falls back to the active project path.

* fix(ui): menu Copy clipboard fallback and softer sidebar tint

useMenuActions falls back to Clipboard API for the native Copy action when the page doesn't intercept. cssGenerator lowers sidebar strong/soft alpha so the tinted surface reads gentler.

* chore(electron): mirror Tauri build/type-check script shape

build script becomes no-op so root 'bun run build' skips packaging. Syntax validation (node --check) moves into type-check. electron:build root script still runs full sidecar+bundle+electron-builder.

* fix(electron): sync app identity, preload path, boot outcome, dev entry

Read version from packages/electron/package.json so 'electron ./main.mjs' dev entry reports the app version instead of Electron's. Bump electron package to 1.9.6 for workspace parity.
Resolve preload via app.getAppPath() in prod (bundle lives in dist-bundle while preload.mjs ships at app root).
Compute and inject __OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ in main + preload so the loading gate dismisses (mirrors Tauri Rust injection).
Dev entry uses ./main.mjs to bypass the stale dist-bundle so source edits apply.

* refactor(open-in-app): split directory and file flows

Header button now opens the project/worktree directory only — drop activeFilePath prop and its Header prop passthrough. FilesView editor dropdown opens the active file only via new openDesktopFileInApp.

Electron main.mjs mirrors Tauri's open-chain logic: buildOpenProjectSpecs (finder/terminal direct, vscode-like via CLI -n, JetBrains via open -na --args) and buildOpenFileSpecs (finder -R reveal, terminal opens parent dir, editors via CLI or open -a). runSpecChain falls through specs until one exits 0.

* fix(files-view): keep floating toolbar mounted while its dropdowns are open

Portalled Base UI menu popups render outside floatingToolbarRef. The document mousedown listener and onMouseLeave collapsed the toolbar as soon as the popup appeared, unmounting the DropdownMenu root and swallowing clicks on its items. Track open dropdowns via onOpenChange and skip the collapse while count > 0; also ignore mousedowns that land inside a dropdown-menu-content/item.

* feat(electron): add quit confirmation with risk poller

Mirrors Tauri's macOS-only behavior: poll /api/openchamber/scheduled-tasks/status and /api/openchamber/tunnel/status every 5s. If active tunnel or running/enabled scheduled tasks are detected, Cmd+Q / dock Quit / menu Quit shows a native warning dialog listing reasons; otherwise quit proceeds silently.

performConfirmedQuit persists window state, kills sidecar, shuts down SSH, and fires a 1500ms unref'd safety timeout that calls app.exit(0) if the normal quit sequence stalls.

* feat(notifications): fix payload parsing, restore-on-click, session deep-link

Normalize input so both sidecar stdout path (flat) and UI IPC path ({ payload: {...} }) work; previous destructuring missed requireHidden (camelCase) and the payload wrapper so notifications showed with empty body.

Click handler restores the window if minimized, shows it if hidden, and focuses. When the notification payload carries sessionId, emit openchamber:open-session which the App listener routes to setCurrentSession — matches the PWA service-worker deep-link behavior. macOS notifications now also use sound 'Glass' for parity with Tauri.

* chore(electron): bump to Electron 41 + latest updater/context-menu

electron ^38.2.0 -> ^41.2.1
electron-updater ^6.6.2 -> ^6.8.3
electron-context-menu ^4.0.4 -> ^4.1.2

Dev boot verified: main process starts, preload exposes globals, API server + quit risk poller + autoUpdater all initialize without errors.

* fix: keep todo row alignment stable when expanding text

Keep checkbox and action buttons vertically centered in collapsed todo rows
Prevent first todo line from shifting when expanding to multiple lines

* fix: make commit highlights visible and input behavior reliable

Switch commit message field to native textarea for predictable auto-resize
Fix AI highlights append flow so inserted text is applied consistently
Make chat scroll-to-bottom control fully circular

* style: increase chat bubble corner radius consistency

Use larger radius for user chat message bubbles
Match chat input container radius to user message styling

* feat(electron): adopt OpenCode playbook improvements

mac: hardenedRuntime + entitlements.mac.plist + notarize + dmg.sign for Apple notarization parity.
single-instance lock + openchamber:// protocol with session/project/host routing (host switch done fully in main via activateMainWindow).
setAppUserModelId for Win toast identity; proxy-bypass-list switch; chdir(homedir) for Finder-launch cwd safety.
shell env probe (\$SHELL -il -> -l) merged into sidecar spawn; PATH deduped.
electron-log with 5MB rotation + 7-day cleanup; autoUpdater.logger wired; startup info log.
webContents zoom locked to 1 (zoom-changed + did-finish-load).
UI: openchamber:open-project -> useDirectoryStore.setDirectory.

* fix(electron): make bootOutcome mutable across re-navigation + project deep-link

host deep-link used to land on chooser because contextBridge exposed bootOutcome as read-only; initScript re-assignment became a silent no-op. drop preload's contextBridge for bootOutcome, inject it via main-world initScript, and move injection from did-finish-load to dom-ready so it lands before React mounts.

project deep-link updated currentDirectory only; activeProjectId stayed stale so the sidebar didn't highlight the new project. switch to projectsStore.setActiveProject (or addProject for new paths) which updates both.

add log.info around deep-link dispatch + host switch for diagnostics.

* fix(electron): desktop_hosts_set IPC args + persist initialHostChoiceCompleted + re-eval bootOutcome

UI calls invoke('desktop_hosts_set', { input: {...} }) but main was reading args.config — every onboarding 'i've completed installation' / host-dialog save wrote nothing, so desktopDefaultHostId stayed null and the chooser screen looped forever.

also:
- writeDesktopHostsConfig now persists desktopInitialHostChoiceCompleted so the tauri-compat flag survives writes.
- readDesktopHostsConfig returns initialHostChoiceCompleted so the UI-side config mirror is complete.
- after writing hosts, recompute state.bootOutcome + state.initScript; a subsequent window.location.reload() picks up target=local/status=ok via dom-ready injection without needing a full app restart.
- app.setName('OpenChamber') early (pre log.initialize) so electron-log logs land in ~/Library/Logs/OpenChamber/ instead of the package-derived '@openchamber/electron' path.

* chore(electron): rename appId to dev.openchamber.desktop

ai.opencode.* is the OpenCode team's reverse-DNS namespace; OpenChamber should not squat there. now that we're on Electron, drop the tauri-era inherited identifier and claim our own under openchamber.dev.

user-facing productName stays "OpenChamber". tauri identifier left as-is — legacy shell on the way out.

* feat(ci): add electron build+notarize+publish jobs to release workflow

three new jobs in release.yml, running in parallel with tauri:

- build-desktop-electron-macos: matrix(arm64, x86_64) on macos-26; installs Developer ID via keychain, runs build:sidecar + bundle:main + electron-builder --mac --arch <> --publish=never (with APPLE_ID / APPLE_APP_SPECIFIC_PASSWORD / APPLE_TEAM_ID env mapped from existing secrets). verifies hardened runtime, stapled notary ticket, required entitlements. uploads DMG/ZIP/blockmaps to the release and emits per-arch latest-mac.yml as a GH artifact.

- combine-electron-manifests: downloads latest-yml-*-apple-darwin artifacts, runs the existing finalize-latest-yml.mjs to merge per-arch files entries into a single latest-mac.yml, uploads combined yml to the release.

- finalize-release: now also waits on the two new jobs before flipping the draft release to published.

also: explicit artifactName in electron-builder config so arm64 and x64 dmg/zip never collide.

electron-updater in main.mjs (setFeedURL btriapitsyn/openchamber) fetches this latest-mac.yml on desktop_check_for_updates; downloadUpdate / quitAndInstall wire through our existing IPC handlers unchanged.

* docs: future-agent brief for tauri -> electron auto-update cutover

self-contained plan for the one-shot migration release that carries existing tauri installs into the electron shell via tauri's updater. written so a fresh agent with no branch context can execute it.

covers: the trick (repackage signed electron .app as a tauri tarball, minisign with existing TAURI_SIGNING_PRIVATE_KEY), workflow surgery on release.yml, rollback plan, validation steps against a real tauri install, and edge cases (CFBundleIdentifier change, notification perms re-prompt, deep-link re-registration).

* docs: soften framing of cutover playbook (no user-shaming)

* chore: mark electron as primary desktop shell; tune dmg installer window

AGENTS.md: explicit note that new desktop work lands in packages/electron/, packages/desktop/ (tauri) is maintenance-only until the cutover described in docs/TAURI_TO_ELECTRON_CUTOVER.md. updated runtime/entry-points/build-commands sections accordingly.

electron/package.json build.dmg: cleaner title ("OpenChamber 1.9.6" without -arch suffix), 660x400 window matching the tauri layout users are used to, icon size 128, explicit app/Applications positions.

* refactor(web): drop bun-specific runtime deps from server

- 11 test files migrated bun:test -> vitest; API (describe/it/expect) is drop-in; all 73 tests pass under vitest run.
- bun:sqlite -> better-sqlite3 in git/service.js::syncSandboxesToOpenCodeDb. api shift is db.query().get()/run() -> db.prepare().get()/run().
- add "test": "vitest run" script in packages/web.

no production code used Bun.* APIs; server is Express-on-Node already. this commit removes the remaining bun-runtime shape so the server module can be imported and booted inside an electron main process.

* feat(electron): boot web server in-process, drop sidecar subprocess

the electron main process now imports @openchamber/web/server/index.js as a workspace dependency and calls startWebUiServer({...}) directly. the returned handle exposes getPort() / stop() and the notification emitter takes an onDesktopNotification callback, so we no longer spawn a bun-compiled sidecar binary and no longer parse stdout for the one-line notify protocol.

- packages/electron/package.json: +@openchamber/web (workspace:*); extraResources drops 'sidecar'; build:sidecar script renamed to build:web-assets (kept the vite build step, dropped the bun compile step).
- packages/electron/main.mjs: remove spawn/kill-stale-sidecar/sidecar path resolver/stdout-prefix parser; rewrite spawnLocalServer to probe a free port (stored | DEFAULT_DESKTOP_PORT | OS-assigned) then import server and await startWebUiServer; killSidecar calls handle.stop({ exitProcess: false }); hoist user shell env (PATH, etc.) onto process.env once so opencode / git / rg children still inherit the expected runtime environment.
- packages/web/server/lib/notifications/emitter-runtime.js: accept an onDesktopNotification callback (late-bindable via setOnDesktopNotification). when set, notifications are dispatched through the callback instead of process.stdout; tauri path still uses stdout when no callback is bound.
- packages/web/server/index.js: main() wires options.onDesktopNotification to notificationEmitterRuntime.setOnDesktopNotification.
- release.yml + AGENTS.md updated for the new script name + runtime shape.

payoff: -300ms cold start on mac, single process in activity monitor, no stdio IPC, no bun binary in the packaged app. tauri sidecar path is untouched.

* build(electron): rebuild native deps explicitly, bump electron-builder

the previous build failed because electron-builder 24.13.3 tried to run \`bun rebuild\` on native deps (better-sqlite3, node-pty) and bun has no rebuild subcommand; it also couldn't find prebuild-install because bun hoists under node_modules/.bun/<pkg>@<ver>/ and never populates node_modules/.bin for transitive deps.

fix:
- bump electron-builder devDep to ^26, whose packageManager detection understands bun workspace layouts.
- add @electron/rebuild devDep + scripts/rebuild-native.mjs. the script rebuilds better-sqlite3 / node-pty / bun-pty against the installed electron version before electron-builder is invoked.
- set build.npmRebuild=false so electron-builder no longer attempts its own broken PM-based rebuild.
- package script: build:web-assets -> bundle:main -> rebuild:native -> electron-builder.

verified: CSC_IDENTITY_AUTO_DISCOVERY=false bun run electron:build produces signed-ad-hoc dmg/zip/blockmap/latest-mac.yml; artifacts land under packages/electron/dist as expected. cold-start from Applications should work (native bindings now match electron 41 node ABI).

* fix(electron): externalize web server + native deps from main bundle

the ESM bundle was statically inlining @openchamber/web transitively, which pulled in bun-pty/src/terminal.ts with its top-level \`import { dlopen } from "bun:ffi"\`. node's ESM loader parses every static import when the bundle loads, so the bun:ffi scheme crashed the packaged app at startup with ERR_UNSUPPORTED_ESM_URL_SCHEME — the runtime guard (if (globalThis.Bun) { await import('bun-pty') }) never got a chance to skip it.

fix: bundle-main.mjs marks @openchamber/web (+ its bun-pty / node-pty / better-sqlite3 transitives) as external. the dynamic \`await import('@openchamber/web/server/index.js')\` in main.mjs stays a runtime resolution; the conditional bun-pty import stays dynamic; native modules load from node_modules via the standard resolver.

* perf(web): classify UI-only deps as devDependencies, shrink packaged app

packages/web is a hybrid package: server code in server/, react UI source in src/, compiled UI output in dist/. the server serves dist/ as static files — it never imports react/radix/codemirror/etc. at runtime. but electron-builder, npm install, and similar tools treat everything under "dependencies" as shipping surface, so all of react + @radix-ui/* + @codemirror/* + @fontsource/* + @simplewebauthn/browser + cmdk + ghostty-web + ... were landing in app.asar even though the same code is already baked into dist/ chunks.

move ~24 UI-only packages to devDependencies. vite + its plugins still install them in dev (bun install fetches devDependencies in workspaces), so \`bun run build\` is unchanged. consumers doing \`npm install @openchamber/web\` no longer pull ~150MB of unused browser-side modules.

measured on aarch64 darwin build:
- app.asar: 281MB -> 44MB (-237MB, -84%)
- .dmg: 320MB -> 132MB (-59%)
- .zip: 305MB -> 129MB (-58%)

verified type-check, ui build, 73 vitest tests, packaged launch.

* chore(electron): center dmg installer icons, use cream brand background

dmg-builder 26 ignored our previous dmg.contents positions against its template background (they stayed at template coords, producing misalignment with the drawn arrow). switch to a solid backgroundColor (#FFFCF0, the splash light tone) so the template image is dropped entirely and our coordinates are authoritative. window tuned to 540x340, iconSize 100, iconTextSize 13.

dmgbuild treats contents coordinates as icon *centers* (not top-left), so with iconSize=100 in a 540 window, x=180 and x=360 place left and right clusters with equal 130px gaps on both sides of the window. y=140 vertically centres the icon+label pair.

* fix(electron): eliminate main-thread freezes in in-process server

Three blocking paths were running sync work on the Electron main event
loop, causing multi-second UI freezes under the new in-process server:

- package-manager.detectPackageManagerDetails fired spawnSync(pnpm/npm/
  yarn/bun bin -g) with 10s timeouts. In desktop runtime PM detection is
  pointless (app is .app bundle, updates via electron-updater) — short-
  circuit when OPENCHAMBER_RUNTIME=desktop. This was the ~5s freeze.
- buildInstalledApps iterated 22 OPEN_IN_APPS × spawnSync(mdfind, sips).
  Converted to execFile promises so child waits yield to the loop.
- orphan-project-file recovery re-scanned disk on every settings read
  (3+/s from fs/list/etc). Cache the outcome per process lifetime.

Also: resolveProjectDirectory prefers settings.lastDirectory over
activeProjectId so file-open from sidebar/chat doesn't 400 with
"Path is outside of active workspace" after the user navigates.

Plus dropdown typeahead fixes in DesktopHostSwitcher/BranchSelector:
stopPropagation on input keys so cmdk doesn't swallow typing.

* feat(electron): restore desktop LAN access for in-process server

spawnLocalServer now reads settings.desktopLanAccessEnabled and binds
on 0.0.0.0 when enabled, so phones/tablets on the same Wi-Fi can open
the app via http://<lan-ip>:<port>. Adds desktop_get_lan_address IPC
(UDP-connect route lookup with networkInterfaces fallback) for the
settings UI to show the reachable URL.

UI and settings plumbing already existed from the sidecar build; only
the Electron main-process wiring was missing.

* chore: added electron package to version bump script

* fix(electron): address PR review — harden IPC surface + polish

P1 security:
- Gate openchamber:invoke and openchamber:dialog:open by webContents
  origin. Only local (loopback / dev file://) senders can call desktop_*.
  Blocks remote hosts loaded via DesktopHostSwitcher from reading local
  files, opening apps, relaunching, etc.
- desktop_read_file now refuses paths outside $HOME / tmpdir and denies
  .ssh/.aws/.gnupg/.config/gh/credentials + .env/.pem/.key by name
  (defense-in-depth behind the origin gate).

P2:
- webPreferences.sandbox:false: add comment explaining preload needs Node
  (contextBridge+ipcRenderer) and why flipping to true would break IPC.
- desktop_set_vibrancy: comment the intentional no-op (no Electron
  equivalent for the Tauri NSVisualEffectView path), drop requiresRestart.
- desktopNative.ts: replace isTauriShell() guards with isDesktopShell()
  so the semantics match (previous check worked only because Electron
  preload exposes a __TAURI__ shim).
- AGENTS.md: correct entry description — server runs in-process, not as
  a sidecar subprocess.

* fix(electron): stop leaking desktop shell APIs to remote renderer pages

Preload was exposing __TAURI__ and __OPENCHAMBER_ELECTRON__ unconditionally,
so after DesktopHostSwitcher navigated the window to a remote OpenChamber
instance the remote UI saw isDesktopShell() === true and tried to invoke
desktop_* IPC. The main-process origin gate then threw "IPC not available
for this origin", surfacing as a user-visible error on the onboarding
screen of the remote.

Preload re-runs on cross-origin navigation; compute current origin up
front and only expose the shell globals + the openchamber:emit listener
when the document is loopback / state.localOrigin / file://. Remote
pages now look like a plain web runtime — no IPC path to reject.

* fix(electron): restore remote UI shell integration via per-command gate

Previous commit stripped __TAURI__ / __OPENCHAMBER_ELECTRON__ from remote
pages wholesale, which broke DesktopHostSwitcher for anyone switched to
a remote instance: no hosts list, "Unknown" probe status, open-in-new-
window dead. Also lost window chrome affordances that the remote UI
needs to render correctly inside the Electron shell.

Switch from an origin-level gate to a per-command allowlist:

- preload.mjs exposes __TAURI__ and __OPENCHAMBER_ELECTRON__ on every
  page (shell identity + IPC channel). __OPENCHAMBER_LOCAL_ORIGIN__ and
  __OPENCHAMBER_MACOS_MAJOR__ also go everywhere since HostSwitcher and
  window chrome depend on them and neither grants capability.
  __OPENCHAMBER_HOME__ stays local-only (leaks the OS username and is
  misleading if consumed as a workspace hint on a remote page).

- main.mjs ipcMain.handle accepts a curated COMMANDS_SAFE_FOR_REMOTE set
  (hosts_get, host_probe, new_window, new_window_at_url, set_window_*,
  is_window_fullscreen, start_window_drag, get_app_version,
  get_lan_address). Filesystem, shell.openPath, installed-apps scans,
  app relaunch, auto-update, hosts_set, dialog:open, read_file stay
  local-only — remote UI doesn't need them and can't weaponize them.

* ci(release): rebuild native modules against Electron ABI before packaging

Electron job skipped rebuild:native so bun install's Node-ABI builds of
better-sqlite3/node-pty/bun-pty shipped into the asar — packaged app
would crash on require. Local bun run package runs the step via
scripts/rebuild-native.mjs (npmRebuild is disabled in package.json);
mirror it in CI and pass ELECTRON_BUILDER_ARCH so the x64 matrix
cross-builds from the arm64 runner.

Tauri job untouched — both builds continue to produce side-by-side
release artifacts (latest.json for Tauri, latest-mac.yml for Electron)
so each shell's updater finds its own manifest.

* ci(release): split Electron arm64/x64 onto native macOS runners

Both Electron matrix entries were running on macos-26 (arm64) and
cross-building x64 from there. Works for Rust/Tauri; brittle for
native Node modules — better-sqlite3, node-pty, bun-pty (with its
rust-pty crate) each have their own cross-target quirks.

Pin arm64 → macos-14 and x64 → macos-13 so node-gyp and
@electron/rebuild build against the host arch. ELECTRON_BUILDER_ARCH
now just mirrors the runner for clarity.

* Revert "ci(release): split Electron arm64/x64 onto native macOS runners"

This reverts commit f217880e49609cf1418818af0f837b333dbb6f42.

* ci(test-build): add Electron DMG job to arm64 dispatch workflow

Parallel job to the existing Tauri DMG builder, same runner + Apple
cert path. Mirrors the release workflow steps (build:web-assets,
bundle:main, rebuild:native, electron-builder) so maintainers can
smoke-test a signed+notarized Electron DMG before merging.

* ci: use electron-builder v26 boolean arch flags

v26 dropped --arch <name> in favour of per-arch booleans (--arm64,
--x64, etc.). Test build was failing at dispatch time; release job
had the same bug latent. Switch both to the supported form.

* fix(electron): route external links to the system browser

<a href> clicks and window.open calls with non-local URLs were loading
inside the Electron BrowserWindow (or spawning a second Electron window
as a makeshift browser). Add an origin-aware navigation guard to each
window: loopback / state.localOrigin / configured desktop hosts keep
their existing in-window behaviour (HostSwitcher, in-window probes);
everything else hands off to shell.openExternal so http/https links
open in the user's default browser.
2026-04-20 15:41:15 +03:00

3287 lines
96 KiB
JavaScript

import simpleGit from 'simple-git';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { execFile } from 'child_process';
import { promisify } from 'util';
const fsp = fs.promises;
const execFileAsync = promisify(execFile);
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
let resolvedGitBinary = null;
const worktreeBootstrapState = new Map();
const WORKTREE_BOOTSTRAP_PENDING = 'pending';
const WORKTREE_BOOTSTRAP_READY = 'ready';
const WORKTREE_BOOTSTRAP_FAILED = 'failed';
const toBootstrapStateKey = (directory) => {
const normalized = normalizeDirectoryPath(directory);
if (!normalized) {
return '';
}
return path.resolve(normalized);
};
const setWorktreeBootstrapState = (directory, status, error = null) => {
const key = toBootstrapStateKey(directory);
if (!key) {
return;
}
worktreeBootstrapState.set(key, {
status,
error: typeof error === 'string' && error.trim().length > 0 ? error.trim() : null,
updatedAt: Date.now(),
});
};
const clearWorktreeBootstrapState = (directory) => {
const key = toBootstrapStateKey(directory);
if (!key) {
return;
}
worktreeBootstrapState.delete(key);
};
const isExecutableFile = (candidate) => {
if (typeof candidate !== 'string' || candidate.trim().length === 0) {
return false;
}
try {
const stat = fs.statSync(candidate);
if (!stat.isFile()) {
return false;
}
if (process.platform === 'win32') {
const ext = path.extname(candidate).toLowerCase();
return ext.length === 0 || ext === '.exe' || ext === '.cmd' || ext === '.bat' || ext === '.com';
}
fs.accessSync(candidate, fs.constants.X_OK);
return true;
} catch {
return false;
}
};
const normalizeGitExecutableCandidate = (candidate) => {
if (typeof candidate !== 'string') {
return null;
}
const trimmed = candidate.trim();
if (!trimmed) {
return null;
}
const ext = path.extname(trimmed).toLowerCase();
if (ext === '.cmd' || ext === '.bat' || ext === '.com') {
const exeCandidate = trimmed.slice(0, -ext.length) + '.exe';
if (isExecutableFile(exeCandidate)) {
return exeCandidate;
}
}
return trimmed;
};
const listPathExecutableCandidates = (binaryName) => {
const currentPath = process.env.PATH || '';
const seen = new Set();
const matches = [];
for (const segment of currentPath.split(path.delimiter)) {
const dir = typeof segment === 'string' ? segment.trim() : '';
if (!dir || seen.has(dir)) {
continue;
}
seen.add(dir);
matches.push(path.join(dir, binaryName));
}
return matches;
};
const listWindowsGitInstallCandidates = () => {
const roots = [
process.env.ProgramFiles,
process.env['ProgramFiles(x86)'],
process.env.LocalAppData,
]
.map((value) => (typeof value === 'string' ? value.trim() : ''))
.filter(Boolean);
const candidates = [];
for (const root of roots) {
candidates.push(path.join(root, 'Git', 'cmd', 'git.exe'));
candidates.push(path.join(root, 'Git', 'bin', 'git.exe'));
candidates.push(path.join(root, 'Git', 'mingw64', 'bin', 'git.exe'));
candidates.push(path.join(root, 'Programs', 'Git', 'cmd', 'git.exe'));
candidates.push(path.join(root, 'Programs', 'Git', 'bin', 'git.exe'));
}
return candidates;
};
const resolveGitBinary = () => {
if (process.platform !== 'win32') {
return 'git';
}
if (resolvedGitBinary) {
return resolvedGitBinary;
}
const explicit = [process.env.GIT_BINARY, process.env.OPENCHAMBER_GIT_BINARY]
.map((value) => (typeof value === 'string' ? value.trim() : ''))
.filter(Boolean);
for (const candidate of explicit) {
if (isExecutableFile(candidate)) {
resolvedGitBinary = candidate;
return resolvedGitBinary;
}
}
const discovered = [
...listPathExecutableCandidates('git.exe'),
...listPathExecutableCandidates('git'),
...listWindowsGitInstallCandidates(),
]
.map(normalizeGitExecutableCandidate)
.filter(Boolean)
.filter((candidate) => isExecutableFile(candidate));
const preferredExe = discovered.find((candidate) => candidate.toLowerCase().endsWith('.exe'));
resolvedGitBinary = preferredExe || discovered[0] || 'git.exe';
return resolvedGitBinary;
};
const getGitBinary = () => resolveGitBinary();
/**
* Escape an SSH key path for use in core.sshCommand.
* Handles Windows/Unix differences and prevents command injection.
*/
function escapeSshKeyPath(sshKeyPath) {
const isWindows = process.platform === 'win32';
// Normalize path first on Windows (convert backslashes to forward slashes)
let normalizedPath = sshKeyPath;
if (isWindows) {
normalizedPath = sshKeyPath.replace(/\\/g, '/');
}
// Validate: reject paths with characters that could enable injection
// Allow only alphanumeric, path separators, dots, dashes, underscores, spaces, and colons (for Windows drives)
// Note: backslash is not in this list since we've already normalized Windows paths
const dangerousChars = /[`$!"';&|<>(){}[\]*?#~]/;
if (dangerousChars.test(normalizedPath)) {
throw new Error(`SSH key path contains invalid characters: ${sshKeyPath}`);
}
if (isWindows) {
// On Windows, Git (via MSYS/MinGW) expects Unix-style paths
// Convert "C:/path" to "/c/path" for MSYS compatibility
let unixPath = normalizedPath;
const driveMatch = unixPath.match(/^([A-Za-z]):\//);
if (driveMatch) {
unixPath = `/${driveMatch[1].toLowerCase()}${unixPath.slice(2)}`;
}
// Use single quotes for the path (prevents shell interpretation)
return `'${unixPath}'`;
} else {
// On Unix, use single quotes and escape any single quotes in the path
// Single quotes prevent all shell interpretation except for single quotes themselves
const escaped = normalizedPath.replace(/'/g, "'\\''");
return `'${escaped}'`;
}
}
/**
* Build the SSH command string for git config
*/
function buildSshCommand(sshKeyPath) {
const escapedPath = escapeSshKeyPath(sshKeyPath);
return `ssh -i ${escapedPath} -o IdentitiesOnly=yes`;
}
const isSocketPath = async (candidate) => {
if (!candidate || typeof candidate !== 'string') {
return false;
}
try {
const stat = await fsp.stat(candidate);
return typeof stat.isSocket === 'function' && stat.isSocket();
} catch {
return false;
}
};
const resolveSshAuthSock = async () => {
const existing = (process.env.SSH_AUTH_SOCK || '').trim();
if (existing) {
return existing;
}
if (process.platform === 'win32') {
return null;
}
const gpgSock = path.join(os.homedir(), '.gnupg', 'S.gpg-agent.ssh');
if (await isSocketPath(gpgSock)) {
return gpgSock;
}
const runGpgconf = async (args) => {
for (const candidate of gpgconfCandidates) {
try {
const { stdout } = await execFileAsync(candidate, args);
return String(stdout || '');
} catch {
continue;
}
}
return '';
};
const candidate = (await runGpgconf(['--list-dirs', 'agent-ssh-socket'])).trim();
if (candidate && await isSocketPath(candidate)) {
return candidate;
}
if (candidate) {
await runGpgconf(['--launch', 'gpg-agent']);
const retried = (await runGpgconf(['--list-dirs', 'agent-ssh-socket'])).trim();
if (retried && await isSocketPath(retried)) {
return retried;
}
}
return null;
};
const buildGitEnv = async () => {
const env = { ...process.env };
if (!env.SSH_AUTH_SOCK || !env.SSH_AUTH_SOCK.trim()) {
const resolved = await resolveSshAuthSock();
if (resolved) {
env.SSH_AUTH_SOCK = resolved;
}
}
return env;
};
const createGit = async (directory) => {
const env = await buildGitEnv();
const spawnOptions = { windowsHide: true };
const binary = getGitBinary();
const hasCustomBinary = typeof binary === 'string' && binary.trim() && binary !== 'git' && binary !== 'git.exe';
const unsafe = hasCustomBinary ? { allowUnsafeCustomBinary: true } : undefined;
if (!directory) {
return simpleGit({ env, spawnOptions, binary, unsafe });
}
return simpleGit({
baseDir: normalizeDirectoryPath(directory),
env,
spawnOptions,
binary,
unsafe,
});
};
const normalizeDirectoryPath = (value) => {
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim();
if (!trimmed) {
return trimmed;
}
if (trimmed === '~') {
return os.homedir();
}
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
return path.join(os.homedir(), trimmed.slice(2));
}
return trimmed;
};
const cleanBranchName = (branch) => {
if (!branch) {
return branch;
}
if (branch.startsWith('refs/heads/')) {
return branch.substring('refs/heads/'.length);
}
if (branch.startsWith('heads/')) {
return branch.substring('heads/'.length);
}
if (branch.startsWith('refs/')) {
return branch.substring('refs/'.length);
}
return branch;
};
const OPENCODE_ADJECTIVES = [
'brave',
'calm',
'clever',
'cosmic',
'crisp',
'curious',
'eager',
'gentle',
'glowing',
'happy',
'hidden',
'jolly',
'kind',
'lucky',
'mighty',
'misty',
'neon',
'nimble',
'playful',
'proud',
'quick',
'quiet',
'shiny',
'silent',
'stellar',
'sunny',
'swift',
'tidy',
'witty',
];
const OPENCODE_NOUNS = [
'cabin',
'cactus',
'canyon',
'circuit',
'comet',
'eagle',
'engine',
'falcon',
'forest',
'garden',
'harbor',
'island',
'knight',
'lagoon',
'meadow',
'moon',
'mountain',
'nebula',
'orchid',
'otter',
'panda',
'pixel',
'planet',
'river',
'rocket',
'sailor',
'squid',
'star',
'tiger',
'wizard',
'wolf',
];
const OPENCODE_WORKTREE_ATTEMPTS = 26;
const getOpenCodeDataPath = () => {
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
return path.join(xdgDataHome, 'opencode');
};
const pickRandom = (values) => values[Math.floor(Math.random() * values.length)];
const generateOpenCodeRandomName = () => `${pickRandom(OPENCODE_ADJECTIVES)}-${pickRandom(OPENCODE_NOUNS)}`;
const slugWorktreeName = (value) => {
return String(value || '')
.trim()
.replace(/^refs\/heads\//, '')
.replace(/^heads\//, '')
.replace(/\s+/g, '-')
.replace(/^\/+|\/+$/g, '')
.split('/').join('-')
.replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-+/, '')
.replace(/-+$/, '')
.slice(0, 80);
};
const parseWorktreePorcelain = (raw) => {
const lines = String(raw || '').split('\n').map((line) => line.trim());
const entries = [];
let current = null;
for (const line of lines) {
if (!line) {
if (current?.worktree) {
entries.push(current);
}
current = null;
continue;
}
if (line.startsWith('worktree ')) {
if (current?.worktree) {
entries.push(current);
}
current = { worktree: line.substring('worktree '.length).trim() };
continue;
}
if (!current) {
continue;
}
if (line.startsWith('HEAD ')) {
current.head = line.substring('HEAD '.length).trim();
continue;
}
if (line.startsWith('branch ')) {
const branchRef = line.substring('branch '.length).trim();
current.branchRef = branchRef;
current.branch = cleanBranchName(branchRef);
}
}
if (current?.worktree) {
entries.push(current);
}
return entries;
};
const canonicalPath = async (input) => {
const absolutePath = path.resolve(input);
const realPath = await fsp.realpath(absolutePath).catch(() => absolutePath);
const normalized = path.normalize(realPath);
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
};
const checkPathExists = async (targetPath) => {
try {
await fsp.stat(targetPath);
return true;
} catch {
return false;
}
};
const normalizeStartRef = (value) => {
const trimmed = String(value || '').trim();
if (!trimmed) {
return 'HEAD';
}
return trimmed;
};
const parseRemoteBranchRef = (value) => {
const trimmed = String(value || '').trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith('refs/remotes/')) {
const rest = trimmed.substring('refs/remotes/'.length);
const slashIndex = rest.indexOf('/');
if (slashIndex <= 0 || slashIndex === rest.length - 1) {
return null;
}
return {
remote: rest.slice(0, slashIndex),
branch: rest.slice(slashIndex + 1),
remoteRef: rest,
fullRef: `refs/remotes/${rest}`,
};
}
if (trimmed.startsWith('remotes/')) {
return parseRemoteBranchRef(`refs/${trimmed}`);
}
const slashIndex = trimmed.indexOf('/');
if (slashIndex <= 0 || slashIndex === trimmed.length - 1) {
return null;
}
return {
remote: trimmed.slice(0, slashIndex),
branch: trimmed.slice(slashIndex + 1),
remoteRef: trimmed,
fullRef: `refs/remotes/${trimmed}`,
};
};
const resolveRemoteBranchRef = async (primaryWorktree, value) => {
const raw = String(value || '').trim();
const parsed = parseRemoteBranchRef(raw);
if (!parsed) {
return null;
}
if (raw.startsWith('refs/remotes/') || raw.startsWith('remotes/')) {
return parsed;
}
const localRef = `refs/heads/${raw}`;
const localExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', localRef]);
if (localExists.success) {
return null;
}
return parsed;
};
const normalizeUpstreamTarget = (remote, branch) => {
const remoteName = String(remote || '').trim();
const branchName = String(branch || '').trim();
if (!remoteName || !branchName) {
return null;
}
return {
remote: remoteName,
branch: branchName,
full: `${remoteName}/${branchName}`,
};
};
const parseGitErrorText = (error) => {
const stderr = typeof error?.stderr === 'string' ? error.stderr : '';
const stdout = typeof error?.stdout === 'string' ? error.stdout : '';
const message = typeof error?.message === 'string' ? error.message : '';
return [stderr, stdout, message]
.map((chunk) => String(chunk || '').trim())
.filter(Boolean)
.join('\n')
.trim();
};
const isNotGitRepositoryError = (error) => {
const text = parseGitErrorText(error);
return /not a git repository/i.test(text);
};
const runGitCommand = async (cwd, args) => {
try {
const { stdout, stderr } = await execFileAsync(getGitBinary(), args, {
cwd,
env: await buildGitEnv(),
windowsHide: true,
maxBuffer: 20 * 1024 * 1024,
});
return {
success: true,
exitCode: 0,
stdout: String(stdout || ''),
stderr: String(stderr || ''),
};
} catch (error) {
return {
success: false,
exitCode: typeof error?.code === 'number' ? error.code : 1,
stdout: String(error?.stdout || ''),
stderr: String(error?.stderr || ''),
message: parseGitErrorText(error),
};
}
};
const runGitCommandOrThrow = async (cwd, args, fallbackMessage) => {
const result = await runGitCommand(cwd, args);
if (!result.success) {
throw new Error(result.message || fallbackMessage || 'Git command failed');
}
return result;
};
const ensureOpenCodeProjectId = async (primaryWorktree) => {
const gitDir = path.join(primaryWorktree, '.git');
const idFile = path.join(gitDir, 'opencode');
const existing = await fsp.readFile(idFile, 'utf8').then((value) => value.trim()).catch(() => '');
if (existing) {
return existing;
}
const rootsResult = await runGitCommandOrThrow(
primaryWorktree,
['rev-list', '--max-parents=0', '--all'],
'Failed to resolve repository roots'
);
const roots = rootsResult.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.sort((a, b) => a.localeCompare(b));
const projectId = roots[0] || '';
if (!projectId) {
throw new Error('Failed to derive OpenCode project ID');
}
await fsp.mkdir(gitDir, { recursive: true }).catch(() => undefined);
await fsp.writeFile(idFile, projectId, 'utf8').catch(() => undefined);
return projectId;
};
const resolveWorktreeProjectContext = async (directory) => {
const directoryPath = normalizeDirectoryPath(directory);
if (!directoryPath) {
throw new Error('Directory is required');
}
const topResult = await runGitCommandOrThrow(
directoryPath,
['rev-parse', '--show-toplevel'],
'Failed to resolve git top-level directory'
);
const sandbox = path.resolve(directoryPath, topResult.stdout.trim());
const commonResult = await runGitCommandOrThrow(
sandbox,
['rev-parse', '--git-common-dir'],
'Failed to resolve git common directory'
);
const commonDir = path.resolve(sandbox, commonResult.stdout.trim());
const primaryWorktree = path.dirname(commonDir);
const projectID = await ensureOpenCodeProjectId(primaryWorktree);
const worktreeRoot = path.join(getOpenCodeDataPath(), 'worktree', projectID);
return {
projectID,
sandbox,
primaryWorktree,
worktreeRoot,
};
};
const listWorktreeEntries = async (directory) => {
const rawResult = await runGitCommandOrThrow(
directory,
['worktree', 'list', '--porcelain'],
'Failed to list git worktrees'
);
return parseWorktreePorcelain(rawResult.stdout);
};
const resolveWorktreeNameCandidates = (baseName) => {
const normalizedBase = slugWorktreeName(baseName || '');
if (!normalizedBase) {
return Array.from({ length: OPENCODE_WORKTREE_ATTEMPTS }, () => generateOpenCodeRandomName());
}
return Array.from({ length: OPENCODE_WORKTREE_ATTEMPTS }, (_, index) => {
if (index === 0) {
return normalizedBase;
}
return `${normalizedBase}-${generateOpenCodeRandomName()}`;
});
};
const resolveCandidateDirectory = async (worktreeRoot, preferredName, explicitBranchName, primaryWorktree) => {
const candidates = resolveWorktreeNameCandidates(preferredName);
for (const name of candidates) {
const directory = path.join(worktreeRoot, name);
if (await checkPathExists(directory)) {
continue;
}
if (explicitBranchName) {
return { name, directory, branch: explicitBranchName };
}
const branch = `openchamber/${name}`;
const branchRef = `refs/heads/${branch}`;
const branchExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', branchRef]);
if (branchExists.success) {
continue;
}
return { name, directory, branch };
}
throw new Error('Failed to generate a unique worktree name');
};
const resolveBranchForExistingMode = async (primaryWorktree, existingBranch, preferredBranchName) => {
const requested = String(existingBranch || '').trim();
if (!requested) {
throw new Error('existingBranch is required in existing mode');
}
const normalizedLocal = cleanBranchName(requested);
const localRef = `refs/heads/${normalizedLocal}`;
const localExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', localRef]);
if (localExists.success) {
return {
localBranch: normalizedLocal,
checkoutRef: normalizedLocal,
createLocalBranch: false,
remoteRef: null,
};
}
const remoteRef = parseRemoteBranchRef(requested);
if (!remoteRef) {
throw new Error(`Branch not found: ${requested}`);
}
const remoteExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', remoteRef.fullRef]);
if (!remoteExists.success) {
await fetchRemoteBranchRef(primaryWorktree, remoteRef.remote, remoteRef.branch).catch(() => undefined);
const recheck = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', remoteRef.fullRef]);
if (!recheck.success) {
throw new Error(`Remote branch not found: ${requested}`);
}
}
const localBranch = cleanBranchName(preferredBranchName || remoteRef.branch || requested);
if (!localBranch) {
throw new Error('Failed to resolve local branch name for existing branch worktree');
}
return {
localBranch,
checkoutRef: remoteRef.remoteRef,
createLocalBranch: true,
remoteRef,
};
};
const findBranchInUse = async (primaryWorktree, localBranchName) => {
if (!localBranchName) {
return null;
}
const entries = await listWorktreeEntries(primaryWorktree);
const targetRef = `refs/heads/${localBranchName}`;
const targetClean = cleanBranchName(targetRef);
return entries.find((entry) => {
const entryRef = String(entry.branchRef || '').trim();
const entryClean = cleanBranchName(entryRef || entry.branch || '');
return entryRef === targetRef || entryClean === targetClean;
}) || null;
};
const runWorktreeStartCommand = async (directory, command) => {
const text = String(command || '').trim();
if (!text) {
return { success: true };
}
if (process.platform === 'win32') {
const result = await execFileAsync('cmd', ['/c', text], {
cwd: directory,
env: await buildGitEnv(),
windowsHide: true,
maxBuffer: 20 * 1024 * 1024,
}).then(({ stdout, stderr }) => ({ success: true, stdout, stderr })).catch((error) => ({
success: false,
stdout: error?.stdout,
stderr: error?.stderr,
message: parseGitErrorText(error),
}));
return result;
}
const result = await execFileAsync('bash', ['-lc', text], {
cwd: directory,
env: await buildGitEnv(),
maxBuffer: 20 * 1024 * 1024,
}).then(({ stdout, stderr }) => ({ success: true, stdout, stderr })).catch((error) => ({
success: false,
stdout: error?.stdout,
stderr: error?.stderr,
message: parseGitErrorText(error),
}));
return result;
};
const loadProjectStartCommand = async (projectID) => {
const storagePath = path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`);
try {
const raw = await fsp.readFile(storagePath, 'utf8');
const parsed = JSON.parse(raw);
const start = typeof parsed?.commands?.start === 'string' ? parsed.commands.start.trim() : '';
return start || '';
} catch {
return '';
}
};
const getProjectStoragePath = (projectID) => {
return path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`);
};
const syncSandboxesToOpenCodeDb = (projectID, sandboxes) => {
try {
const Database = require('better-sqlite3');
const dbPath = path.join(getOpenCodeDataPath(), 'opencode.db');
if (!fs.existsSync(dbPath)) return;
const db = new Database(dbPath);
try {
const row = db.prepare('SELECT sandboxes FROM project WHERE id = ?').get(projectID);
if (!row) return;
const json = JSON.stringify(sandboxes);
db.prepare('UPDATE project SET sandboxes = ?, time_updated = ? WHERE id = ?').run(json, Date.now(), projectID);
} finally {
db.close();
}
} catch (error) {
console.warn('Failed to sync sandboxes to OpenCode DB:', error instanceof Error ? error.message : String(error));
}
};
const updateProjectSandboxes = async (projectID, primaryWorktree, updater) => {
const storagePath = getProjectStoragePath(projectID);
await fsp.mkdir(path.dirname(storagePath), { recursive: true });
const now = Date.now();
const base = {
id: projectID,
worktree: primaryWorktree,
vcs: 'git',
sandboxes: [],
time: {
created: now,
updated: now,
},
};
const parsed = await fsp.readFile(storagePath, 'utf8').then((raw) => JSON.parse(raw)).catch(() => null);
const current = parsed && typeof parsed === 'object' ? { ...base, ...parsed } : base;
current.id = String(current.id || projectID);
current.worktree = String(current.worktree || primaryWorktree);
current.vcs = current.vcs || 'git';
current.sandboxes = Array.isArray(current.sandboxes)
? current.sandboxes.map((entry) => String(entry || '').trim()).filter(Boolean)
: [];
const createdAt = Number(current?.time?.created);
current.time = {
created: Number.isFinite(createdAt) && createdAt > 0 ? createdAt : now,
updated: now,
};
updater(current);
current.sandboxes = [...new Set(
(Array.isArray(current.sandboxes) ? current.sandboxes : [])
.map((entry) => String(entry || '').trim())
.filter(Boolean)
)];
await fsp.writeFile(storagePath, `${JSON.stringify(current, null, 2)}\n`, 'utf8');
// Sync to OpenCode's SQLite database so project.sandboxes is visible via the SDK
syncSandboxesToOpenCodeDb(projectID, current.sandboxes);
};
const syncProjectSandboxAdd = async (projectID, primaryWorktree, sandboxPath) => {
const sandbox = String(sandboxPath || '').trim();
if (!sandbox) {
return;
}
await updateProjectSandboxes(projectID, primaryWorktree, (project) => {
if (!project.sandboxes.includes(sandbox)) {
project.sandboxes.push(sandbox);
}
});
};
const syncProjectSandboxRemove = async (projectID, primaryWorktree, sandboxPath) => {
const sandbox = String(sandboxPath || '').trim();
if (!sandbox) {
return;
}
await updateProjectSandboxes(projectID, primaryWorktree, (project) => {
project.sandboxes = project.sandboxes.filter((entry) => entry !== sandbox);
});
};
const runWorktreeStartScripts = async (directory, projectID, startCommand) => {
const projectStart = await loadProjectStartCommand(projectID);
if (projectStart) {
const projectResult = await runWorktreeStartCommand(directory, projectStart);
if (!projectResult.success) {
console.warn('Worktree project start command failed:', projectResult.message || projectResult.stderr || projectResult.stdout);
return;
}
}
const extraCommand = String(startCommand || '').trim();
if (!extraCommand) {
return;
}
const extraResult = await runWorktreeStartCommand(directory, extraCommand);
if (!extraResult.success) {
console.warn('Worktree start command failed:', extraResult.message || extraResult.stderr || extraResult.stdout);
}
};
const queueWorktreeBootstrap = (args) => {
const {
directory,
projectID,
primaryWorktree,
localBranch,
setUpstream,
upstreamRemote,
upstreamBranch,
ensureRemoteName,
ensureRemoteUrl,
startCommand,
} = args;
setTimeout(() => {
const run = async () => {
await runGitCommandOrThrow(directory, ['reset', '--hard'], 'Failed to populate worktree');
if (setUpstream) {
await applyUpstreamConfiguration({
primaryWorktree,
worktreeDirectory: directory,
localBranch,
setUpstream,
upstreamRemote,
upstreamBranch,
ensureRemoteName,
ensureRemoteUrl,
}).catch((error) => {
console.warn('Worktree upstream configuration failed:', error instanceof Error ? error.message : String(error));
});
}
await runWorktreeStartScripts(directory, projectID, startCommand).catch((error) => {
console.warn('Worktree start script task failed:', error instanceof Error ? error.message : String(error));
});
setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_READY);
};
void run().catch((error) => {
setWorktreeBootstrapState(
directory,
WORKTREE_BOOTSTRAP_FAILED,
error instanceof Error ? error.message : String(error)
);
console.warn('Worktree bootstrap task failed:', error instanceof Error ? error.message : String(error));
});
}, 0);
};
const ensureRemoteWithUrl = async (primaryWorktree, remoteName, remoteUrl) => {
const name = String(remoteName || '').trim();
const url = String(remoteUrl || '').trim();
if (!name || !url) {
return;
}
const getUrl = await runGitCommand(primaryWorktree, ['remote', 'get-url', name]);
if (getUrl.success) {
const currentUrl = String(getUrl.stdout || '').trim();
if (currentUrl !== url) {
await runGitCommandOrThrow(primaryWorktree, ['remote', 'set-url', name, url], 'Failed to update git remote URL');
}
return;
}
await runGitCommandOrThrow(primaryWorktree, ['remote', 'add', name, url], 'Failed to add git remote');
};
const fetchRemoteBranchRef = async (primaryWorktree, remoteName, branchName) => {
const remote = String(remoteName || '').trim();
const branch = String(branchName || '').trim();
if (!remote || !branch) {
return;
}
const refspec = `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`;
await runGitCommandOrThrow(
primaryWorktree,
['fetch', remote, refspec],
`Failed to fetch ${remote}/${branch}`
);
};
const checkRemoteBranchExists = async (primaryWorktree, remoteName, branchName, remoteUrl = '') => {
const remote = String(remoteName || '').trim();
const branch = String(branchName || '').trim();
const url = String(remoteUrl || '').trim();
if (!remote || !branch) {
return { success: false, found: false };
}
const target = url || remote;
const lsRemote = await runGitCommand(
primaryWorktree,
['ls-remote', '--heads', target, `refs/heads/${branch}`]
);
if (!lsRemote.success) {
return { success: false, found: false };
}
return {
success: true,
found: Boolean(String(lsRemote.stdout || '').trim()),
};
};
const setBranchTrackingFallback = async (worktreeDirectory, localBranch, upstream) => {
await runGitCommandOrThrow(
worktreeDirectory,
['config', `branch.${localBranch}.remote`, upstream.remote],
`Failed to set branch.${localBranch}.remote`
);
await runGitCommandOrThrow(
worktreeDirectory,
['config', `branch.${localBranch}.merge`, `refs/heads/${upstream.branch}`],
`Failed to set branch.${localBranch}.merge`
);
};
const applyUpstreamConfiguration = async (args) => {
const {
primaryWorktree,
worktreeDirectory,
localBranch,
setUpstream,
upstreamRemote,
upstreamBranch,
ensureRemoteName,
ensureRemoteUrl,
} = args;
if (!setUpstream) {
return;
}
if (ensureRemoteName && ensureRemoteUrl) {
await ensureRemoteWithUrl(primaryWorktree, ensureRemoteName, ensureRemoteUrl);
}
const upstream = normalizeUpstreamTarget(upstreamRemote, upstreamBranch);
if (!upstream || !localBranch) {
return;
}
let fetched = true;
try {
await fetchRemoteBranchRef(primaryWorktree, upstream.remote, upstream.branch);
} catch {
fetched = false;
}
if (fetched) {
await runGitCommandOrThrow(
worktreeDirectory,
['branch', `--set-upstream-to=${upstream.full}`, localBranch],
`Failed to set upstream to ${upstream.full}`
);
return;
}
await setBranchTrackingFallback(worktreeDirectory, localBranch, upstream);
};
export async function isGitRepository(directory) {
const directoryPath = normalizeDirectoryPath(directory);
if (!directoryPath || !fs.existsSync(directoryPath)) {
return false;
}
const result = await runGitCommand(directoryPath, ['rev-parse', '--git-dir']);
return result.success;
}
export async function getGlobalIdentity() {
const git = await createGit();
try {
const userName = await git.getConfig('user.name', 'global').catch(() => null);
const userEmail = await git.getConfig('user.email', 'global').catch(() => null);
const sshCommand = await git.getConfig('core.sshCommand', 'global').catch(() => null);
return {
userName: userName?.value || null,
userEmail: userEmail?.value || null,
sshCommand: sshCommand?.value || null
};
} catch (error) {
console.error('Failed to get global Git identity:', error);
return {
userName: null,
userEmail: null,
sshCommand: null
};
}
}
export async function getRemoteUrl(directory, remoteName = 'origin') {
const git = await createGit(directory);
try {
const url = await git.remote(['get-url', remoteName]);
return url?.trim() || null;
} catch {
return null;
}
}
export async function getCurrentIdentity(directory) {
const git = await createGit(directory);
try {
const userName = await git.getConfig('user.name', 'local').catch(() =>
git.getConfig('user.name', 'global')
);
const userEmail = await git.getConfig('user.email', 'local').catch(() =>
git.getConfig('user.email', 'global')
);
const sshCommand = await git.getConfig('core.sshCommand', 'local').catch(() =>
git.getConfig('core.sshCommand', 'global')
);
return {
userName: userName?.value || null,
userEmail: userEmail?.value || null,
sshCommand: sshCommand?.value || null
};
} catch (error) {
console.error('Failed to get current Git identity:', error);
return {
userName: null,
userEmail: null,
sshCommand: null
};
}
}
export async function hasLocalIdentity(directory) {
const git = await createGit(directory);
try {
const localName = await git.getConfig('user.name', 'local').catch(() => null);
const localEmail = await git.getConfig('user.email', 'local').catch(() => null);
return Boolean(localName?.value || localEmail?.value);
} catch {
return false;
}
}
export async function setLocalIdentity(directory, profile) {
const git = await createGit(directory);
try {
await git.addConfig('user.name', profile.userName, false, 'local');
await git.addConfig('user.email', profile.userEmail, false, 'local');
const authType = profile.authType || 'ssh';
if (authType === 'ssh' && profile.sshKey) {
await git.addConfig(
'core.sshCommand',
buildSshCommand(profile.sshKey),
false,
'local'
);
await git.raw(['config', '--local', '--unset', 'credential.helper']).catch(() => {});
} else if (authType === 'token' && profile.host) {
await git.addConfig(
'credential.helper',
'store',
false,
'local'
);
await git.raw(['config', '--local', '--unset', 'core.sshCommand']).catch(() => {});
}
return true;
} catch (error) {
console.error('Failed to set Git identity:', error);
throw error;
}
}
export async function getStatus(directory, options = {}) {
const directoryPath = normalizeDirectoryPath(directory);
const git = await createGit(directoryPath);
const lightMode = options.mode === 'light';
try {
// Use -uall to show all untracked files individually, not just directories
const status = await git.status(['-uall']);
// Light mode: skip numstat + new-file line counting for faster response
const [stagedStatsRaw, workingStatsRaw] = lightMode
? ['', '']
: await Promise.all([
git.raw(['diff', '--cached', '--numstat']).catch(() => ''),
git.raw(['diff', '--numstat']).catch(() => ''),
]);
const diffStatsMap = new Map();
const accumulateStats = (raw) => {
if (!raw) return;
raw
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.forEach((line) => {
const parts = line.split('\t');
if (parts.length < 3) {
return;
}
const [insertionsRaw, deletionsRaw, ...pathParts] = parts;
const path = pathParts.join('\t');
if (!path) {
return;
}
const insertions = insertionsRaw === '-' ? 0 : parseInt(insertionsRaw, 10) || 0;
const deletions = deletionsRaw === '-' ? 0 : parseInt(deletionsRaw, 10) || 0;
const existing = diffStatsMap.get(path) || { insertions: 0, deletions: 0 };
diffStatsMap.set(path, {
insertions: existing.insertions + insertions,
deletions: existing.deletions + deletions,
});
});
};
accumulateStats(stagedStatsRaw);
accumulateStats(workingStatsRaw);
const diffStats = Object.fromEntries(diffStatsMap.entries());
const newFileStats = lightMode ? [] : await Promise.all(
status.files.map(async (file) => {
const working = (file.working_dir || '').trim();
const indexStatus = (file.index || '').trim();
const statusCode = working || indexStatus;
if (statusCode !== '?' && statusCode !== 'A') {
return null;
}
const existing = diffStats[file.path];
if (existing && existing.insertions > 0) {
return null;
}
const absolutePath = path.join(directoryPath, file.path);
try {
const stat = await fsp.stat(absolutePath);
if (!stat.isFile()) {
return null;
}
const buffer = await fsp.readFile(absolutePath);
if (buffer.indexOf(0) !== -1) {
return {
path: file.path,
insertions: existing?.insertions ?? 0,
deletions: existing?.deletions ?? 0,
};
}
const normalized = buffer.toString('utf8').replace(/\r\n/g, '\n');
if (!normalized.length) {
return {
path: file.path,
insertions: 0,
deletions: 0,
};
}
const segments = normalized.split('\n');
if (normalized.endsWith('\n')) {
segments.pop();
}
const lineCount = segments.length;
return {
path: file.path,
insertions: lineCount,
deletions: 0,
};
} catch (error) {
console.warn('Failed to estimate diff stats for new file', file.path, error);
return null;
}
})
);
for (const entry of newFileStats) {
if (!entry) continue;
diffStats[entry.path] = {
insertions: entry.insertions,
deletions: entry.deletions,
};
}
const selectBaseRefForUnpublished = async () => {
const candidates = [];
const originHead = await git
.raw(['symbolic-ref', '-q', 'refs/remotes/origin/HEAD'])
.then((value) => String(value || '').trim())
.catch(() => '');
if (originHead) {
// "refs/remotes/origin/main" -> "origin/main"
candidates.push(originHead.replace(/^refs\/remotes\//, ''));
}
candidates.push('origin/main', 'origin/master', 'main', 'master');
for (const ref of candidates) {
const exists = await git
.raw(['rev-parse', '--verify', ref])
.then((value) => String(value || '').trim())
.catch(() => '');
if (exists) return ref;
}
return null;
};
let tracking = status.tracking || null;
let ahead = status.ahead;
let behind = status.behind;
// When no upstream is configured (common for new worktree branches), Git doesn't report ahead/behind.
// We still want to show the number of unpublished commits to the user.
// Light mode skips this — the basic ahead/behind from git status is sufficient for polling.
if (!lightMode && !tracking && status.current) {
const baseRef = await selectBaseRefForUnpublished();
if (baseRef) {
const countRaw = await git
.raw(['rev-list', '--count', `${baseRef}..HEAD`])
.then((value) => String(value || '').trim())
.catch(() => '');
const count = parseInt(countRaw, 10);
if (Number.isFinite(count)) {
ahead = count;
behind = 0;
}
}
}
// Check for in-progress operations
let mergeInProgress = null;
let rebaseInProgress = null;
try {
// Check MERGE_HEAD for merge in progress
const mergeHeadExists = await git
.raw(['rev-parse', '--verify', '--quiet', 'MERGE_HEAD'])
.then(() => true)
.catch(() => false);
if (mergeHeadExists) {
const mergeHead = await git.raw(['rev-parse', 'MERGE_HEAD']).catch(() => '');
const headSha = mergeHead.trim().slice(0, 7);
// Only set mergeInProgress if we actually have a valid head SHA
if (headSha) {
const mergeMsg = await fsp.readFile(path.join(directoryPath, '.git', 'MERGE_MSG'), 'utf8').catch(() => '');
mergeInProgress = {
head: headSha,
message: mergeMsg.split('\n')[0] || '',
};
}
}
} catch {
// ignore
}
try {
// Check for rebase in progress (.git/rebase-merge or .git/rebase-apply)
const rebaseMergeExists = await fsp.stat(path.join(directoryPath, '.git', 'rebase-merge')).then(() => true).catch(() => false);
const rebaseApplyExists = await fsp.stat(path.join(directoryPath, '.git', 'rebase-apply')).then(() => true).catch(() => false);
if (rebaseMergeExists || rebaseApplyExists) {
const rebaseDir = rebaseMergeExists ? 'rebase-merge' : 'rebase-apply';
const headName = await fsp.readFile(path.join(directoryPath, '.git', rebaseDir, 'head-name'), 'utf8').catch(() => '');
const onto = await fsp.readFile(path.join(directoryPath, '.git', rebaseDir, 'onto'), 'utf8').catch(() => '');
const headNameTrimmed = headName.trim().replace('refs/heads/', '');
const ontoTrimmed = onto.trim().slice(0, 7);
// Only set rebaseInProgress if we have valid data
if (headNameTrimmed || ontoTrimmed) {
rebaseInProgress = {
headName: headNameTrimmed,
onto: ontoTrimmed,
};
}
}
} catch {
// ignore
}
return {
current: status.current,
tracking,
ahead,
behind,
files: status.files.map((f) => ({
path: f.path,
index: f.index,
working_dir: f.working_dir,
})),
isClean: status.isClean(),
diffStats: lightMode ? undefined : diffStats,
mergeInProgress,
rebaseInProgress,
};
} catch (error) {
if (!isNotGitRepositoryError(error)) {
console.error('Failed to get Git status:', error);
}
throw error;
}
}
export async function getDiff(directory, { path, staged = false, contextLines = 3 } = {}) {
const git = await createGit(directory);
try {
const args = ['diff', '--no-color'];
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
args.push(`-U${Math.max(0, contextLines)}`);
}
if (staged) {
args.push('--cached');
}
if (path) {
args.push('--', path);
}
const diff = await git.raw(args);
if (diff && diff.trim().length > 0) {
return diff;
}
if (staged) {
return diff;
}
try {
await git.raw(['ls-files', '--error-unmatch', path]);
return diff;
} catch {
const noIndexArgs = ['diff', '--no-color'];
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
noIndexArgs.push(`-U${Math.max(0, contextLines)}`);
}
noIndexArgs.push('--no-index', '--', '/dev/null', path);
try {
const noIndexDiff = await git.raw(noIndexArgs);
return noIndexDiff;
} catch (noIndexError) {
// git diff --no-index returns exit code 1 when differences exist (not a real error)
if (noIndexError.exitCode === 1 && noIndexError.message) {
return noIndexError.message;
}
throw noIndexError;
}
}
} catch (error) {
console.error('Failed to get Git diff:', error);
throw error;
}
}
export async function getRangeDiff(directory, { base, head, path, contextLines = 3 } = {}) {
const git = await createGit(directory);
const baseRef = typeof base === 'string' ? base.trim() : '';
const headRef = typeof head === 'string' ? head.trim() : '';
if (!baseRef || !headRef) {
throw new Error('base and head are required');
}
// Prefer remote-tracking base ref so merged commits don't reappear
// when local base branch is stale (common when user stays on feature branch).
let resolvedBase = baseRef;
const originCandidate = `refs/remotes/origin/${baseRef}`;
try {
const verified = await git.raw(['rev-parse', '--verify', originCandidate]);
if (verified && verified.trim()) {
resolvedBase = `origin/${baseRef}`;
}
} catch {
// ignore
}
const args = ['diff', '--no-color'];
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
args.push(`-U${Math.max(0, contextLines)}`);
}
args.push(`${resolvedBase}...${headRef}`);
if (path) {
args.push('--', path);
}
const diff = await git.raw(args);
return diff;
}
export async function getRangeFiles(directory, { base, head } = {}) {
const git = await createGit(directory);
const baseRef = typeof base === 'string' ? base.trim() : '';
const headRef = typeof head === 'string' ? head.trim() : '';
if (!baseRef || !headRef) {
throw new Error('base and head are required');
}
let resolvedBase = baseRef;
const originCandidate = `refs/remotes/origin/${baseRef}`;
try {
const verified = await git.raw(['rev-parse', '--verify', originCandidate]);
if (verified && verified.trim()) {
resolvedBase = `origin/${baseRef}`;
}
} catch {
// ignore
}
const raw = await git.raw(['diff', '--name-only', `${resolvedBase}...${headRef}`]);
return String(raw || '')
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
}
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif'];
const BINARY_SNIFF_BYTES = 8192;
function isImageFile(filePath) {
const ext = filePath.split('.').pop()?.toLowerCase();
return IMAGE_EXTENSIONS.includes(ext || '');
}
function getImageMimeType(filePath) {
const ext = filePath.split('.').pop()?.toLowerCase();
const mimeMap = {
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'svg': 'image/svg+xml',
'webp': 'image/webp',
'ico': 'image/x-icon',
'bmp': 'image/bmp',
'avif': 'image/avif',
};
return mimeMap[ext] || 'application/octet-stream';
}
const parseIsBinaryFromNumstat = (raw) => {
const text = String(raw || '').trim();
if (!text) {
return false;
}
// Expected format: <added>\t<deleted>\t<path>
const firstLine = text.split('\n').map((line) => line.trim()).find(Boolean) || '';
const [added, deleted] = firstLine.split('\t');
return added === '-' || deleted === '-';
};
const looksBinaryBySniff = async (absolutePath) => {
try {
const handle = await fsp.open(absolutePath, 'r');
try {
const buffer = Buffer.alloc(BINARY_SNIFF_BYTES);
const { bytesRead } = await handle.read(buffer, 0, BINARY_SNIFF_BYTES, 0);
if (bytesRead <= 0) {
return false;
}
return buffer.subarray(0, bytesRead).includes(0);
} finally {
await handle.close();
}
} catch {
return false;
}
};
const isBinaryDiff = async (directoryPath, filePath, staged) => {
// Fast path: ask git for numstat. For binary, it returns "-\t-\t<path>".
const args = ['diff', '--numstat'];
if (staged) {
args.push('--cached');
}
args.push('--', filePath);
const result = await runGitCommand(directoryPath, args);
if (parseIsBinaryFromNumstat(result.stdout)) {
return true;
}
// Fallback for untracked files (diff output is empty): use --no-index against /dev/null
if (!staged) {
const tracked = await runGitCommand(directoryPath, ['ls-files', '--error-unmatch', '--', filePath]).then((r) => r.success);
if (!tracked) {
const noIndex = await runGitCommand(directoryPath, ['diff', '--no-index', '--numstat', '--', '/dev/null', filePath]);
if (parseIsBinaryFromNumstat(noIndex.stdout) || parseIsBinaryFromNumstat(noIndex.stderr) || parseIsBinaryFromNumstat(noIndex.message)) {
return true;
}
const text = `${noIndex.stdout || ''}\n${noIndex.stderr || ''}\n${noIndex.message || ''}`.toLowerCase();
if (text.includes('binary files') || text.includes('git binary patch')) {
return true;
}
}
}
return false;
};
export async function getFileDiff(directory, { path: filePath, staged = false } = {}) {
if (!directory || !filePath) {
throw new Error('directory and path are required for getFileDiff');
}
const directoryPath = normalizeDirectoryPath(directory);
const git = await createGit(directoryPath);
const isImage = isImageFile(filePath);
const mimeType = isImage ? getImageMimeType(filePath) : null;
if (!isImage) {
const absolutePath = path.join(directoryPath, filePath);
const isBinaryBySniff = await looksBinaryBySniff(absolutePath);
const isBinary = isBinaryBySniff || (await isBinaryDiff(directoryPath, filePath, staged));
if (isBinary) {
return {
original: '',
modified: '',
path: filePath,
isBinary: true,
};
}
}
let original = '';
try {
if (isImage) {
// For images, use git show with raw output and convert to base64
try {
const { stdout } = await execFileAsync(getGitBinary(), ['show', `HEAD:${filePath}`], {
cwd: directoryPath,
encoding: 'buffer',
windowsHide: true,
maxBuffer: 50 * 1024 * 1024, // 50MB max
});
if (stdout && stdout.length > 0) {
original = `data:${mimeType};base64,${stdout.toString('base64')}`;
}
} catch {
original = '';
}
} else {
original = await git.show([`HEAD:${filePath}`]);
}
} catch {
original = '';
}
const fullPath = path.join(directoryPath, filePath);
let modified = '';
try {
const stat = await fsp.stat(fullPath);
if (stat.isFile()) {
if (isImage) {
// For images, read as binary and convert to data URL
const buffer = await fsp.readFile(fullPath);
modified = `data:${mimeType};base64,${buffer.toString('base64')}`;
} else {
modified = await fsp.readFile(fullPath, 'utf8');
}
}
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
modified = '';
} else {
console.error('Failed to read modified file contents for diff:', error);
throw error;
}
}
return {
original,
modified,
path: filePath,
isBinary: false,
};
}
export async function revertFile(directory, filePath) {
const directoryPath = normalizeDirectoryPath(directory);
const git = await createGit(directoryPath);
const repoRoot = path.resolve(directoryPath);
const absoluteTarget = path.resolve(repoRoot, filePath);
if (!absoluteTarget.startsWith(repoRoot + path.sep) && absoluteTarget !== repoRoot) {
throw new Error('Invalid file path');
}
const isTracked = await git
.raw(['ls-files', '--error-unmatch', filePath])
.then(() => true)
.catch(() => false);
if (!isTracked) {
try {
await git.raw(['clean', '-f', '-d', '--', filePath]);
return;
} catch (cleanError) {
try {
await fsp.rm(absoluteTarget, { recursive: true, force: true });
return;
} catch (fsError) {
if (fsError && typeof fsError === 'object' && fsError.code === 'ENOENT') {
return;
}
console.error('Failed to remove untracked file during revert:', fsError);
throw fsError;
}
}
}
try {
await git.raw(['restore', '--staged', filePath]);
} catch (error) {
await git.raw(['reset', 'HEAD', '--', filePath]).catch(() => {});
}
try {
await git.raw(['restore', filePath]);
} catch (error) {
try {
await git.raw(['checkout', '--', filePath]);
} catch (fallbackError) {
console.error('Failed to revert git file:', fallbackError);
throw fallbackError;
}
}
}
export async function collectDiffs(directory, files = []) {
const results = [];
for (const filePath of files) {
try {
const diff = await getDiff(directory, { path: filePath });
if (diff && diff.trim().length > 0) {
results.push({ path: filePath, diff });
}
} catch (error) {
console.error(`Failed to diff ${filePath}:`, error);
}
}
return results;
}
export async function pull(directory, options = {}) {
const git = await createGit(directory);
try {
const result = await git.pull(
options.remote || 'origin',
options.branch,
options.options || {}
);
return {
success: true,
summary: result.summary,
files: result.files,
insertions: result.insertions,
deletions: result.deletions
};
} catch (error) {
console.error('Failed to pull:', error);
throw error;
}
}
export async function push(directory, options = {}) {
const git = await createGit(directory);
const describePushError = (error) => {
const fromNestedGit = error?.git && typeof error.git === 'object'
? [error.git.message, error.git.stderr, error.git.stdout]
: [];
const candidates = [
error?.message,
error?.stderr,
error?.stdout,
...fromNestedGit,
]
.map((value) => String(value || '').trim())
.filter(Boolean);
return candidates[0] || 'Failed to push to remote';
};
const buildUpstreamOptions = (raw) => {
if (Array.isArray(raw)) {
return raw.includes('--set-upstream') ? raw : [...raw, '--set-upstream'];
}
if (raw && typeof raw === 'object') {
return { ...raw, '--set-upstream': null };
}
return ['--set-upstream'];
};
const looksLikeMissingUpstream = (error) => {
const message = String(error?.message || error?.stderr || '').toLowerCase();
return (
message.includes('has no upstream') ||
message.includes('no upstream') ||
message.includes('set-upstream') ||
message.includes('set upstream') ||
(message.includes('upstream') && message.includes('push') && message.includes('-u'))
);
};
const normalizePushResult = (result) => {
return {
success: true,
pushed: result.pushed,
repo: result.repo,
ref: result.ref,
};
};
const remote = String(options.remote || '').trim();
if (!remote && !options.branch) {
try {
await git.push();
return {
success: true,
pushed: [],
repo: directory,
ref: null,
};
} catch (error) {
if (!looksLikeMissingUpstream(error)) {
const message = describePushError(error);
console.error('Failed to push:', error);
throw new Error(message);
}
try {
const status = await git.status();
const branch = status.current;
const remotes = await git.getRemotes(true);
const fallbackRemote = remotes.find((entry) => entry.name === 'origin')?.name || remotes[0]?.name;
if (!branch || !fallbackRemote) {
const message = describePushError(error);
throw new Error(message);
}
const result = await git.push(fallbackRemote, branch, buildUpstreamOptions(options.options));
return normalizePushResult(result);
} catch (fallbackError) {
const message = describePushError(fallbackError);
console.error('Failed to push (including upstream fallback):', fallbackError);
throw new Error(message);
}
}
}
const remoteName = remote || 'origin';
// If caller didn't specify a branch, this is the common "Push"/"Commit & Push" path.
// When there's no upstream yet (typical for freshly-created worktree branches), publish it on first push.
if (!options.branch) {
try {
const status = await git.status();
if (status.current && !status.tracking) {
const result = await git.push(remoteName, status.current, buildUpstreamOptions(options.options));
return normalizePushResult(result);
}
} catch (error) {
// If we can't read status, fall back to the regular push path below.
console.warn('Failed to read git status before push:', error);
}
}
try {
const result = await git.push(remoteName, options.branch, options.options || {});
return normalizePushResult(result);
} catch (error) {
// Last-resort fallback: retry with upstream if the error suggests it's missing.
if (!looksLikeMissingUpstream(error)) {
const message = describePushError(error);
console.error('Failed to push:', error);
throw new Error(message);
}
try {
const status = await git.status();
const branch = options.branch || status.current;
if (!branch) {
console.error('Failed to push: missing branch name for upstream setup:', error);
throw error;
}
const result = await git.push(remoteName, branch, buildUpstreamOptions(options.options));
return normalizePushResult(result);
} catch (fallbackError) {
const message = describePushError(fallbackError);
console.error('Failed to push (including upstream fallback):', fallbackError);
throw new Error(message);
}
}
}
export async function deleteRemoteBranch(directory, options = {}) {
const { branch, remote } = options;
if (!branch) {
throw new Error('branch is required to delete remote branch');
}
const git = await createGit(directory);
const targetBranch = branch.startsWith('refs/heads/')
? branch.substring('refs/heads/'.length)
: branch;
const remoteName = remote || 'origin';
try {
await git.push(remoteName, `:${targetBranch}`);
return { success: true };
} catch (error) {
console.error('Failed to delete remote branch:', error);
throw error;
}
}
export async function fetch(directory, options = {}) {
const git = await createGit(directory);
try {
await git.fetch(
options.remote || 'origin',
options.branch,
options.options || {}
);
return { success: true };
} catch (error) {
console.error('Failed to fetch:', error);
throw error;
}
}
export async function commit(directory, message, options = {}) {
const git = await createGit(directory);
try {
const requestedFiles = Array.isArray(options.files)
? options.files
.map((value) => String(value || '').trim())
.filter(Boolean)
: [];
let filesToCommit = requestedFiles;
if (options.addAll) {
await git.add('.');
} else if (requestedFiles.length > 0) {
const status = await git.status();
const fileStatusByPath = new Map(status.files.map((file) => [file.path, file]));
filesToCommit = requestedFiles.filter((filePath) => fileStatusByPath.has(filePath));
if (filesToCommit.length === 0) {
throw new Error('No selected files are available to commit. Refresh git status and try again.');
}
const filesNeedingAdd = filesToCommit.filter((filePath) => {
const fileStatus = fileStatusByPath.get(filePath);
if (!fileStatus) {
return false;
}
const alreadyFullyStaged = fileStatus.index !== ' ' && fileStatus.working_dir === ' ';
return !alreadyFullyStaged;
});
if (filesNeedingAdd.length > 0) {
await git.add(filesNeedingAdd);
}
}
const commitArgs =
!options.addAll && filesToCommit.length > 0
? filesToCommit
: undefined;
let result;
try {
result = await git.commit(message, commitArgs);
} catch (error) {
const gitErrorText = parseGitErrorText(error);
const isPathspecError = gitErrorText.includes('pathspec') && gitErrorText.includes('did not match any files');
if (!isPathspecError || !commitArgs || commitArgs.length === 0) {
throw error;
}
// Fallback for deleted/stale selections: commit currently staged changes.
result = await git.commit(message);
}
return {
success: true,
commit: result.commit,
branch: result.branch,
summary: result.summary
};
} catch (error) {
console.error('Failed to commit:', error);
throw error;
}
}
export async function getBranches(directory) {
const git = await createGit(directory);
try {
const result = await git.branch();
const allBranches = result.all;
const remoteBranches = allBranches.filter(branch => branch.startsWith('remotes/'));
const activeRemoteBranches = await filterActiveRemoteBranches(git, remoteBranches);
const filteredAll = [
...allBranches.filter(branch => !branch.startsWith('remotes/')),
...activeRemoteBranches
];
return {
all: filteredAll,
current: result.current,
branches: result.branches
};
} catch (error) {
console.error('Failed to get branches:', error);
throw error;
}
}
async function filterActiveRemoteBranches(git, remoteBranches) {
try {
const lsRemoteResult = await git.raw(['ls-remote', '--heads', 'origin']);
const actualRemoteBranches = new Set();
const lines = lsRemoteResult.trim().split('\n');
for (const line of lines) {
if (line.includes('\trefs/heads/')) {
const branchName = line.split('\t')[1].replace('refs/heads/', '');
actualRemoteBranches.add(branchName);
}
}
return remoteBranches.filter(remoteBranch => {
const match = remoteBranch.match(/^remotes\/[^\/]+\/(.+)$/);
if (!match) return false;
const branchName = match[1];
return actualRemoteBranches.has(branchName);
});
} catch (error) {
console.warn('Failed to filter active remote branches, returning all:', error.message);
return remoteBranches;
}
}
export async function createBranch(directory, branchName, options = {}) {
const git = await createGit(directory);
try {
await git.checkoutBranch(branchName, options.startPoint || 'HEAD');
return { success: true, branch: branchName };
} catch (error) {
console.error('Failed to create branch:', error);
throw error;
}
}
export async function checkoutBranch(directory, branchName) {
const git = await createGit(directory);
try {
await git.checkout(branchName);
return { success: true, branch: branchName };
} catch (error) {
console.error('Failed to checkout branch:', error);
throw error;
}
}
export async function getWorktrees(directory) {
const directoryPath = normalizeDirectoryPath(directory);
if (!directoryPath || !fs.existsSync(directoryPath) || !fs.existsSync(path.join(directoryPath, '.git'))) {
return [];
}
try {
const result = await runGitCommandOrThrow(
directoryPath,
['worktree', 'list', '--porcelain'],
'Failed to list git worktrees'
);
return parseWorktreePorcelain(result.stdout).map((entry) => ({
head: entry.head || '',
name: path.basename(entry.worktree || ''),
branch: entry.branch || '',
path: entry.worktree,
}));
} catch (error) {
console.warn('Failed to list worktrees, returning empty list:', error?.message || error);
return [];
}
}
export async function validateWorktreeCreate(directory, input = {}) {
const mode = input?.mode === 'existing' ? 'existing' : 'new';
const errors = [];
try {
const context = await resolveWorktreeProjectContext(directory);
const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim());
const startRef = normalizeStartRef(input?.startRef);
const ensureRemoteName = String(input?.ensureRemoteName || '').trim();
const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim();
let localBranch = '';
let inferredUpstream = null;
if (mode === 'existing') {
try {
const requestedExistingBranch = String(input?.existingBranch || '').trim();
const parsedExistingRemote = await resolveRemoteBranchRef(context.primaryWorktree, requestedExistingBranch);
if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedExistingRemote.remote) {
const lsRemote = await runGitCommand(
context.primaryWorktree,
['ls-remote', '--heads', ensureRemoteUrl, `refs/heads/${parsedExistingRemote.branch}`]
);
if (!lsRemote.success) {
throw new Error(`Unable to query remote ${ensureRemoteName}`);
}
if (!String(lsRemote.stdout || '').trim()) {
throw new Error(`Remote branch not found: ${parsedExistingRemote.remoteRef}`);
}
localBranch = cleanBranchName(preferredBranchName || parsedExistingRemote.branch);
inferredUpstream = {
remote: parsedExistingRemote.remote,
branch: parsedExistingRemote.branch,
};
} else {
const resolved = await resolveBranchForExistingMode(context.primaryWorktree, requestedExistingBranch, preferredBranchName);
localBranch = resolved.localBranch || '';
if (resolved.remoteRef) {
inferredUpstream = {
remote: resolved.remoteRef.remote,
branch: resolved.remoteRef.branch,
};
}
}
} catch (error) {
errors.push({
code: 'branch_not_found',
message: error instanceof Error ? error.message : 'Existing branch not found',
});
}
} else {
if (preferredBranchName) {
const exists = await runGitCommand(context.primaryWorktree, ['show-ref', '--verify', '--quiet', `refs/heads/${preferredBranchName}`]);
if (exists.success) {
errors.push({
code: 'branch_exists',
message: `Branch already exists: ${preferredBranchName}`,
});
}
localBranch = preferredBranchName;
}
const parsedRemoteRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
if (startRef && startRef !== 'HEAD') {
if (parsedRemoteRef && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedRemoteRef.remote) {
const remoteCheck = await checkRemoteBranchExists(
context.primaryWorktree,
parsedRemoteRef.remote,
parsedRemoteRef.branch,
ensureRemoteUrl
);
if (!remoteCheck.success) {
errors.push({
code: 'remote_unreachable',
message: `Unable to query remote ${ensureRemoteName}`,
});
} else if (!remoteCheck.found) {
errors.push({
code: 'start_ref_not_found',
message: `Remote branch not found: ${parsedRemoteRef.remoteRef}`,
});
}
} else if (parsedRemoteRef) {
const remoteCheck = await checkRemoteBranchExists(
context.primaryWorktree,
parsedRemoteRef.remote,
parsedRemoteRef.branch
);
if (!remoteCheck.success) {
errors.push({
code: 'remote_unreachable',
message: `Unable to query remote ${parsedRemoteRef.remote}`,
});
} else if (!remoteCheck.found) {
errors.push({
code: 'start_ref_not_found',
message: `Remote branch not found: ${parsedRemoteRef.remoteRef}`,
});
}
} else {
const startRefExists = await runGitCommand(context.primaryWorktree, ['rev-parse', '--verify', '--quiet', startRef]);
if (!startRefExists.success) {
errors.push({
code: 'start_ref_not_found',
message: `Start ref not found: ${startRef}`,
});
}
}
}
if (parsedRemoteRef) {
inferredUpstream = {
remote: parsedRemoteRef.remote,
branch: parsedRemoteRef.branch,
};
}
}
if (localBranch) {
const inUse = await findBranchInUse(context.primaryWorktree, localBranch);
if (inUse) {
errors.push({
code: 'branch_in_use',
message: `Branch is already checked out in ${inUse.worktree}`,
});
}
}
if ((ensureRemoteName && !ensureRemoteUrl) || (!ensureRemoteName && ensureRemoteUrl)) {
errors.push({
code: 'invalid_remote_config',
message: 'Both ensureRemoteName and ensureRemoteUrl are required together',
});
}
const shouldSetUpstream = Boolean(input?.setUpstream);
if (shouldSetUpstream) {
const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim();
const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim();
if (!upstreamRemote || !upstreamBranch) {
errors.push({
code: 'upstream_incomplete',
message: 'upstreamRemote and upstreamBranch are required when setUpstream is true',
});
} else {
const remoteExists = await runGitCommand(context.primaryWorktree, ['remote', 'get-url', upstreamRemote]);
if (!remoteExists.success && (!ensureRemoteName || ensureRemoteName !== upstreamRemote)) {
errors.push({
code: 'remote_not_found',
message: `Remote not found: ${upstreamRemote}`,
});
}
}
}
return {
ok: errors.length === 0,
errors,
resolved: {
mode,
localBranch: localBranch || null,
},
};
} catch (error) {
return {
ok: false,
errors: [{
code: 'validation_failed',
message: error instanceof Error ? error.message : 'Failed to validate worktree creation',
}],
};
}
}
export async function previewWorktreeCreate(directory, input = {}) {
const mode = input?.mode === 'existing' ? 'existing' : 'new';
const context = await resolveWorktreeProjectContext(directory);
await fsp.mkdir(context.worktreeRoot, { recursive: true });
const preferredName = String(input?.worktreeName || input?.name || '').trim();
const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim());
const candidate = await resolveCandidateDirectory(
context.worktreeRoot,
preferredName,
mode === 'new' && preferredBranchName ? preferredBranchName : '',
context.primaryWorktree
);
return {
name: candidate.name,
branch: mode === 'new' ? candidate.branch : preferredBranchName,
path: candidate.directory,
};
}
export async function createWorktree(directory, input = {}) {
const mode = input?.mode === 'existing' ? 'existing' : 'new';
const context = await resolveWorktreeProjectContext(directory);
await fsp.mkdir(context.worktreeRoot, { recursive: true });
const preferredName = String(input?.worktreeName || input?.name || '').trim();
const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim());
const startRef = normalizeStartRef(input?.startRef);
const ensureRemoteName = String(input?.ensureRemoteName || '').trim();
const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim();
const candidate = await resolveCandidateDirectory(
context.worktreeRoot,
preferredName,
mode === 'new' && preferredBranchName ? preferredBranchName : '',
context.primaryWorktree
);
let localBranch = '';
let inferredUpstream = null;
const worktreeAddArgs = ['worktree', 'add', '--no-checkout'];
if (mode === 'existing') {
const requestedExistingBranch = String(input?.existingBranch || '').trim();
const parsedExistingRemote = await resolveRemoteBranchRef(context.primaryWorktree, requestedExistingBranch);
if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && parsedExistingRemote.remote === ensureRemoteName) {
await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl);
await fetchRemoteBranchRef(context.primaryWorktree, parsedExistingRemote.remote, parsedExistingRemote.branch);
}
const resolved = await resolveBranchForExistingMode(context.primaryWorktree, requestedExistingBranch, preferredBranchName);
localBranch = resolved.localBranch;
const inUse = await findBranchInUse(context.primaryWorktree, localBranch);
if (inUse) {
throw new Error(`Branch is already checked out in ${inUse.worktree}`);
}
if (resolved.createLocalBranch) {
worktreeAddArgs.push('-b', localBranch);
}
worktreeAddArgs.push(candidate.directory, resolved.checkoutRef);
if (resolved.remoteRef) {
inferredUpstream = {
remote: resolved.remoteRef.remote,
branch: resolved.remoteRef.branch,
};
}
} else {
localBranch = candidate.branch;
if (!localBranch) {
throw new Error('Failed to resolve branch name for new worktree');
}
const branchExists = await runGitCommand(context.primaryWorktree, ['show-ref', '--verify', '--quiet', `refs/heads/${localBranch}`]);
if (branchExists.success) {
throw new Error(`Branch already exists: ${localBranch}`);
}
const inUse = await findBranchInUse(context.primaryWorktree, localBranch);
if (inUse) {
throw new Error(`Branch is already checked out in ${inUse.worktree}`);
}
worktreeAddArgs.push('-b', localBranch, candidate.directory);
if (startRef && startRef !== 'HEAD') {
worktreeAddArgs.push(startRef);
}
const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
if (parsedRemoteStartRef) {
inferredUpstream = {
remote: parsedRemoteStartRef.remote,
branch: parsedRemoteStartRef.branch,
};
}
}
if (ensureRemoteName && ensureRemoteUrl) {
await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl);
}
if (mode === 'new') {
const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
if (parsedRemoteStartRef) {
await fetchRemoteBranchRef(context.primaryWorktree, parsedRemoteStartRef.remote, parsedRemoteStartRef.branch);
}
}
await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree');
try {
await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory);
} catch (error) {
console.warn('Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error));
}
const shouldSetUpstream = Boolean(input?.setUpstream);
const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim();
const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim();
setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING);
queueWorktreeBootstrap({
directory: candidate.directory,
projectID: context.projectID,
primaryWorktree: context.primaryWorktree,
localBranch,
setUpstream: shouldSetUpstream,
upstreamRemote,
upstreamBranch,
ensureRemoteName,
ensureRemoteUrl,
startCommand: input?.startCommand,
});
const headResult = await runGitCommand(candidate.directory, ['rev-parse', 'HEAD']);
const head = String(headResult.stdout || '').trim();
return {
head,
name: candidate.name,
branch: localBranch,
path: candidate.directory,
};
}
export async function getWorktreeBootstrapStatus(directory) {
const key = toBootstrapStateKey(directory);
if (!key) {
throw new Error('Worktree directory is required');
}
const current = worktreeBootstrapState.get(key);
if (current) {
return current;
}
return {
status: WORKTREE_BOOTSTRAP_READY,
error: null,
updatedAt: Date.now(),
};
}
export async function removeWorktree(directory, input = {}) {
const targetDirectory = normalizeDirectoryPath(input?.directory);
if (!targetDirectory) {
throw new Error('Worktree directory is required');
}
const context = await resolveWorktreeProjectContext(directory);
const deleteLocalBranch = input?.deleteLocalBranch === true;
const targetCanonical = await canonicalPath(targetDirectory);
const primaryCanonical = await canonicalPath(context.primaryWorktree);
if (targetCanonical === primaryCanonical) {
throw new Error('Cannot remove the primary workspace');
}
const entries = await listWorktreeEntries(context.primaryWorktree);
const matchedEntry = await (async () => {
for (const entry of entries) {
if (!entry?.worktree) {
continue;
}
const entryCanonical = await canonicalPath(entry.worktree);
if (entryCanonical === targetCanonical) {
return entry;
}
}
return null;
})();
if (!matchedEntry?.worktree) {
const targetExists = await checkPathExists(targetDirectory);
if (targetExists) {
await fsp.rm(targetDirectory, { recursive: true, force: true });
}
try {
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, targetDirectory);
} catch (error) {
console.warn('Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
}
clearWorktreeBootstrapState(targetDirectory);
return true;
}
await runGitCommandOrThrow(
context.primaryWorktree,
['worktree', 'remove', '--force', matchedEntry.worktree],
'Failed to remove git worktree'
);
if (deleteLocalBranch) {
const branchName = cleanBranchName(String(matchedEntry.branchRef || matchedEntry.branch || '').trim());
if (branchName) {
await runGitCommandOrThrow(
context.primaryWorktree,
['branch', '-D', branchName],
`Failed to delete local branch ${branchName}`
);
}
}
try {
await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, matchedEntry.worktree);
} catch (error) {
console.warn('Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
}
clearWorktreeBootstrapState(matchedEntry.worktree);
return true;
}
export async function deleteBranch(directory, branch, options = {}) {
const git = await createGit(directory);
try {
const branchName = branch.startsWith('refs/heads/')
? branch.substring('refs/heads/'.length)
: branch;
const args = ['branch', options.force ? '-D' : '-d', branchName];
await git.raw(args);
return { success: true };
} catch (error) {
console.error('Failed to delete branch:', error);
throw error;
}
}
export async function getLog(directory, options = {}) {
const git = await createGit(directory);
try {
const maxCount = options.maxCount || 50;
const baseLog = await git.log({
maxCount,
from: options.from,
to: options.to,
file: options.file
});
const logArgs = [
'log',
`--max-count=${maxCount}`,
'--date=iso',
'--pretty=format:%H%x1f%an%x1f%ae%x1f%ad%x1f%s%x1e',
'--shortstat'
];
if (options.from && options.to) {
logArgs.push(`${options.from}..${options.to}`);
} else if (options.from) {
logArgs.push(`${options.from}..HEAD`);
} else if (options.to) {
logArgs.push(options.to);
}
if (options.file) {
logArgs.push('--', options.file);
}
const rawLog = await git.raw(logArgs);
const records = rawLog
.split('\x1e')
.map((entry) => entry.trim())
.filter(Boolean);
const statsMap = new Map();
records.forEach((record) => {
const lines = record.split('\n').filter((line) => line.trim().length > 0);
const header = lines.shift() || '';
const [hash] = header.split('\x1f');
if (!hash) {
return;
}
let filesChanged = 0;
let insertions = 0;
let deletions = 0;
lines.forEach((line) => {
const filesMatch = line.match(/(\d+)\s+files?\s+changed/);
const insertMatch = line.match(/(\d+)\s+insertions?\(\+\)/);
const deleteMatch = line.match(/(\d+)\s+deletions?\(-\)/);
if (filesMatch) {
filesChanged = parseInt(filesMatch[1], 10);
}
if (insertMatch) {
insertions = parseInt(insertMatch[1], 10);
}
if (deleteMatch) {
deletions = parseInt(deleteMatch[1], 10);
}
});
statsMap.set(hash, { filesChanged, insertions, deletions });
});
const merged = baseLog.all.map((entry) => {
const stats = statsMap.get(entry.hash) || { filesChanged: 0, insertions: 0, deletions: 0 };
return {
hash: entry.hash,
date: entry.date,
message: entry.message,
refs: entry.refs || '',
body: entry.body || '',
author_name: entry.author_name,
author_email: entry.author_email,
filesChanged: stats.filesChanged,
insertions: stats.insertions,
deletions: stats.deletions
};
});
return {
all: merged,
latest: merged[0] || null,
total: baseLog.total
};
} catch (error) {
console.error('Failed to get log:', error);
throw error;
}
}
export async function isLinkedWorktree(directory) {
const git = await createGit(directory);
try {
const [gitDir, gitCommonDir] = await Promise.all([
git.raw(['rev-parse', '--git-dir']).then((output) => output.trim()),
git.raw(['rev-parse', '--git-common-dir']).then((output) => output.trim())
]);
return gitDir !== gitCommonDir;
} catch (error) {
console.error('Failed to determine worktree type:', error);
return false;
}
}
export async function validateWorktreeDirectory(directory, worktreeRoot) {
const directoryPath = normalizeDirectoryPath(directory);
const rootPath = normalizeDirectoryPath(worktreeRoot);
if (!directoryPath || !rootPath) {
return {
valid: false,
insideWorktreeRoot: false,
resolvedWorktreeRoot: null,
resolvedCwd: null,
};
}
const isRepo = await isGitRepository(directoryPath);
if (!isRepo) {
return {
valid: false,
insideWorktreeRoot: false,
resolvedWorktreeRoot: null,
resolvedCwd: null,
};
}
const resolvedCwd = await canonicalPath(directoryPath);
const resolvedRoot = await canonicalPath(rootPath);
const inside = resolvedCwd.startsWith(resolvedRoot + path.sep) || resolvedCwd === resolvedRoot;
return {
valid: true,
insideWorktreeRoot: inside,
resolvedWorktreeRoot: resolvedRoot,
resolvedCwd,
};
}
export async function canonicalizeWorktreeState(directory) {
const directoryPath = normalizeDirectoryPath(directory);
if (!directoryPath) {
return {
worktreeRoot: null,
cwd: null,
branch: null,
headState: 'detached',
worktreeStatus: 'not-a-repo',
legacy: false,
degraded: false,
attentionReason: null,
};
}
const isRepo = await isGitRepository(directoryPath);
if (!isRepo) {
return {
worktreeRoot: null,
cwd: null,
branch: null,
headState: 'detached',
worktreeStatus: 'not-a-repo',
legacy: false,
degraded: false,
attentionReason: null,
};
}
const cwd = await canonicalPath(directoryPath);
const git = await createGit(directoryPath);
let worktreeRoot = null;
let worktreeStatus = 'ready';
let headState = /** @type {'branch' | 'detached' | 'unborn'} */ ('branch');
let branch = null;
let attentionReason = /** @type {'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null} */ (null);
try {
const context = await resolveWorktreeProjectContext(directoryPath);
worktreeRoot = await canonicalPath(context.worktreeRoot);
} catch {
worktreeStatus = 'invalid';
}
try {
const symbolicRef = await git.raw(['symbolic-ref', '-q', 'HEAD']).catch(() => '');
if (symbolicRef.trim()) {
headState = 'branch';
branch = cleanBranchName(symbolicRef.trim());
} else {
const revParse = await git.raw(['rev-parse', 'HEAD']).catch(() => '');
if (!revParse.trim()) {
headState = 'unborn';
branch = null;
} else {
headState = 'detached';
branch = revParse.trim().slice(0, 7);
}
}
} catch {
headState = 'unborn';
branch = null;
}
// Detect attention reasons from getStatus side-effects
try {
const status = await git.status(['-uall']);
if (status.current && (await git.raw(['rev-parse', '--verify', 'MERGE_HEAD']).then(() => true).catch(() => false))) {
attentionReason = 'merge';
} else {
const rebaseMerge = await fsp.stat(path.join(directoryPath, '.git', 'rebase-merge')).then(() => true).catch(() => false);
const rebaseApply = await fsp.stat(path.join(directoryPath, '.git', 'rebase-apply')).then(() => true).catch(() => false);
if (rebaseMerge || rebaseApply) {
attentionReason = 'rebase';
} else if (status.conflicted && status.conflicted.length > 0) {
const cherryPickHead = await fsp.stat(path.join(directoryPath, '.git', 'CHERRY_PICK_HEAD')).then(() => true).catch(() => false);
const revertHead = await fsp.stat(path.join(directoryPath, '.git', 'REVERT_HEAD')).then(() => true).catch(() => false);
if (cherryPickHead) attentionReason = 'cherry-pick';
else if (revertHead) attentionReason = 'revert';
}
}
} catch {
// Status check failed — ignore
}
return {
worktreeRoot,
cwd,
branch,
headState,
worktreeStatus,
legacy: false,
degraded: false,
attentionReason,
};
}
export async function getCommitFiles(directory, commitHash) {
const git = await createGit(directory);
try {
const numstatRaw = await git.raw([
'show',
'--numstat',
'--format=',
commitHash
]);
const files = [];
const lines = numstatRaw.trim().split('\n').filter(Boolean);
for (const line of lines) {
const parts = line.split('\t');
if (parts.length < 3) continue;
const [insertionsRaw, deletionsRaw, ...pathParts] = parts;
const filePath = pathParts.join('\t');
if (!filePath) continue;
const insertions = insertionsRaw === '-' ? 0 : parseInt(insertionsRaw, 10) || 0;
const deletions = deletionsRaw === '-' ? 0 : parseInt(deletionsRaw, 10) || 0;
const isBinary = insertionsRaw === '-' && deletionsRaw === '-';
let changeType = 'M';
let displayPath = filePath;
if (filePath.includes(' => ')) {
changeType = 'R';
const match = filePath.match(/(?:\{[^}]*\s=>\s[^}]*\}|.*\s=>\s.*)/);
if (match) {
displayPath = filePath;
}
}
files.push({
path: displayPath,
insertions,
deletions,
isBinary,
changeType
});
}
const nameStatusRaw = await git.raw([
'show',
'--name-status',
'--format=',
commitHash
]).catch(() => '');
const statusMap = new Map();
const statusLines = nameStatusRaw.trim().split('\n').filter(Boolean);
for (const line of statusLines) {
const match = line.match(/^([AMDRC])\d*\t(.+)$/);
if (match) {
const [, status, path] = match;
statusMap.set(path, status);
}
}
for (const file of files) {
const basePath = file.path.includes(' => ')
? file.path.split(' => ').pop()?.replace(/[{}]/g, '') || file.path
: file.path;
const status = statusMap.get(basePath) || statusMap.get(file.path);
if (status) {
file.changeType = status;
}
}
return { files };
} catch (error) {
console.error('Failed to get commit files:', error);
throw error;
}
}
export async function renameBranch(directory, oldName, newName) {
const git = await createGit(directory);
try {
const normalizedOldName = cleanBranchName(String(oldName || '').trim());
const normalizedNewName = cleanBranchName(String(newName || '').trim());
const previousRemote = await git
.raw(['config', '--get', `branch.${normalizedOldName}.remote`])
.then((value) => String(value || '').trim())
.catch(() => '');
const previousMerge = await git
.raw(['config', '--get', `branch.${normalizedOldName}.merge`])
.then((value) => String(value || '').trim())
.catch(() => '');
// Use git branch -m command to rename the branch
await git.raw(['branch', '-m', oldName, newName]);
if (previousRemote && previousMerge && normalizedNewName) {
const previousMergeBranch = cleanBranchName(previousMerge);
const nextMergeBranch =
previousMergeBranch === normalizedOldName
? normalizedNewName
: previousMergeBranch;
const upstream = normalizeUpstreamTarget(previousRemote, nextMergeBranch);
if (upstream) {
try {
await runGitCommandOrThrow(
directory,
['branch', `--set-upstream-to=${upstream.full}`, normalizedNewName],
`Failed to set upstream to ${upstream.full}`
);
} catch {
await setBranchTrackingFallback(directory, normalizedNewName, upstream);
}
}
}
return { success: true, branch: newName };
} catch (error) {
console.error('Failed to rename branch:', error);
throw error;
}
}
export async function getRemotes(directory) {
const git = await createGit(directory);
try {
const remotes = await git.getRemotes(true);
return remotes.map((remote) => ({
name: remote.name,
fetchUrl: remote.refs.fetch,
pushUrl: remote.refs.push
}));
} catch (error) {
console.error('Failed to get remotes:', error);
throw error;
}
}
export async function removeRemote(directory, options = {}) {
const remoteName = String(options.remote || '').trim();
if (!remoteName) {
throw new Error('remote is required to remove a remote');
}
if (remoteName === 'origin') {
throw new Error('Cannot remove origin remote');
}
const git = await createGit(directory);
try {
await git.removeRemote(remoteName);
return { success: true };
} catch (error) {
console.error('Failed to remove remote:', error);
throw error;
}
}
export async function rebase(directory, options = {}) {
const git = await createGit(directory);
try {
const { onto } = options;
if (!onto) {
throw new Error('onto parameter is required for rebase');
}
await git.rebase([onto]);
return {
success: true,
conflict: false
};
} catch (error) {
const errorMessage = String(error?.message || error || '').toLowerCase();
const isConflict = errorMessage.includes('conflict') ||
errorMessage.includes('could not apply') ||
errorMessage.includes('merge conflict');
if (isConflict) {
// Get list of conflicted files
const status = await git.status().catch(() => ({ conflicted: [] }));
return {
success: false,
conflict: true,
conflictFiles: status.conflicted || []
};
}
console.error('Failed to rebase:', error);
throw error;
}
}
export async function abortRebase(directory) {
const git = await createGit(directory);
try {
await git.rebase(['--abort']);
return { success: true };
} catch (error) {
console.error('Failed to abort rebase:', error);
throw error;
}
}
export async function merge(directory, options = {}) {
const git = await createGit(directory);
try {
const { branch } = options;
if (!branch) {
throw new Error('branch parameter is required for merge');
}
await git.merge([branch]);
return {
success: true,
conflict: false
};
} catch (error) {
const errorMessage = String(error?.message || error || '').toLowerCase();
const isConflict = errorMessage.includes('conflict') ||
errorMessage.includes('merge conflict') ||
errorMessage.includes('automatic merge failed');
if (isConflict) {
// Get list of conflicted files
const status = await git.status().catch(() => ({ conflicted: [] }));
return {
success: false,
conflict: true,
conflictFiles: status.conflicted || []
};
}
console.error('Failed to merge:', error);
throw error;
}
}
export async function abortMerge(directory) {
const git = await createGit(directory);
try {
await git.merge(['--abort']);
return { success: true };
} catch (error) {
console.error('Failed to abort merge:', error);
throw error;
}
}
export async function continueRebase(directory) {
const directoryPath = normalizeDirectoryPath(directory);
const git = await createGit(directoryPath);
try {
// Set GIT_EDITOR to prevent editor prompts
await git.env('GIT_EDITOR', 'true').rebase(['--continue']);
return { success: true, conflict: false };
} catch (error) {
const errorMessage = String(error?.message || error || '').toLowerCase();
const isConflict = errorMessage.includes('conflict') ||
errorMessage.includes('needs merge') ||
errorMessage.includes('unmerged') ||
errorMessage.includes('fix conflicts');
if (isConflict) {
const status = await git.status().catch(() => ({ conflicted: [] }));
return {
success: false,
conflict: true,
conflictFiles: status.conflicted || []
};
}
// Check for "nothing to commit" which means rebase step is complete
if (errorMessage.includes('nothing to commit') || errorMessage.includes('no changes')) {
// Skip this commit and continue
try {
await git.env('GIT_EDITOR', 'true').rebase(['--skip']);
return { success: true, conflict: false };
} catch {
// If skip also fails, the rebase may be complete
return { success: true, conflict: false };
}
}
console.error('Failed to continue rebase:', error);
throw error;
}
}
export async function continueMerge(directory) {
const directoryPath = normalizeDirectoryPath(directory);
const git = await createGit(directoryPath);
try {
// Check if there are still unmerged files
const status = await git.status();
if (status.conflicted && status.conflicted.length > 0) {
return {
success: false,
conflict: true,
conflictFiles: status.conflicted
};
}
// For merge, we commit after resolving conflicts
// Use --no-edit to use the default merge commit message
await git.env('GIT_EDITOR', 'true').commit([], { '--no-edit': null });
return { success: true, conflict: false };
} catch (error) {
const errorMessage = String(error?.message || error || '').toLowerCase();
const isConflict = errorMessage.includes('conflict') ||
errorMessage.includes('needs merge') ||
errorMessage.includes('unmerged') ||
errorMessage.includes('fix conflicts');
if (isConflict) {
const status = await git.status().catch(() => ({ conflicted: [] }));
return {
success: false,
conflict: true,
conflictFiles: status.conflicted || []
};
}
// "nothing to commit" can happen if all conflicts resolved to one side
if (errorMessage.includes('nothing to commit') || errorMessage.includes('no changes added')) {
// The merge is effectively complete (all changes already committed or no changes needed)
return { success: true, conflict: false };
}
console.error('Failed to continue merge:', error);
throw error;
}
}
export async function getConflictDetails(directory) {
const directoryPath = normalizeDirectoryPath(directory);
const git = await createGit(directoryPath);
try {
// Get git status --porcelain
const statusPorcelain = await git.raw(['status', '--porcelain']).catch(() => '');
// Get unmerged files
const unmergedFilesRaw = await git.raw(['diff', '--name-only', '--diff-filter=U']).catch(() => '');
const unmergedFiles = unmergedFilesRaw
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
// Get current diff
const diff = await git.raw(['diff']).catch(() => '');
// Detect operation type and get head info
let operation = 'merge';
let headInfo = '';
// Check for MERGE_HEAD (merge in progress)
const mergeHeadExists = await git
.raw(['rev-parse', '--verify', '--quiet', 'MERGE_HEAD'])
.then(() => true)
.catch(() => false);
if (mergeHeadExists) {
operation = 'merge';
const mergeHead = await git.raw(['rev-parse', 'MERGE_HEAD']).catch(() => '');
const mergeMsg = await fsp
.readFile(path.join(directoryPath, '.git', 'MERGE_MSG'), 'utf8')
.catch(() => '');
headInfo = `MERGE_HEAD: ${mergeHead.trim()}\n${mergeMsg}`;
} else {
// Check for REBASE_HEAD (rebase in progress)
const rebaseHeadExists = await git
.raw(['rev-parse', '--verify', '--quiet', 'REBASE_HEAD'])
.then(() => true)
.catch(() => false);
if (rebaseHeadExists) {
operation = 'rebase';
const rebaseHead = await git.raw(['rev-parse', 'REBASE_HEAD']).catch(() => '');
headInfo = `REBASE_HEAD: ${rebaseHead.trim()}`;
}
}
return {
statusPorcelain: statusPorcelain.trim(),
unmergedFiles,
diff: diff.trim(),
headInfo: headInfo.trim(),
operation,
};
} catch (error) {
console.error('Failed to get conflict details:', error);
throw error;
}
}
// ============== Stash Operations ==============
export async function stash(directory, options = {}) {
const git = await createGit(directory);
try {
const args = ['stash', 'push'];
// Include untracked files by default
if (options.includeUntracked !== false) {
args.push('--include-untracked');
}
if (options.message) {
args.push('-m', options.message);
}
await git.raw(args);
return { success: true };
} catch (error) {
console.error('Failed to stash:', error);
throw error;
}
}
export async function stashPop(directory) {
const git = await createGit(directory);
try {
await git.raw(['stash', 'pop']);
return { success: true };
} catch (error) {
console.error('Failed to pop stash:', error);
throw error;
}
}