diff --git a/CHANGELOG.md b/CHANGELOG.md index cd741270..050fcf3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- VS Code extension: reorganized extension location. It is now on the right sidebar for VSCode and on the left for Cursor/Windsurf. +- Worktrees now do not create a remote branch by default; only the push action publishes the branch to remote. + + ## [1.3.9] - 2025-12-30 - Added skills management to settings with the ability to create, edit, and delete skills (make sure you have the latest OpenCode version for skills support). diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 12697619..ee9f3b44 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2847,7 +2847,7 @@ dependencies = [ [[package]] name = "openchamber-desktop" -version = "1.3.8" +version = "1.3.9" dependencies = [ "anyhow", "axum", diff --git a/packages/desktop/src-tauri/src/commands/git.rs b/packages/desktop/src-tauri/src/commands/git.rs index d7627175..71e6bd0c 100644 --- a/packages/desktop/src-tauri/src/commands/git.rs +++ b/packages/desktop/src-tauri/src/commands/git.rs @@ -808,6 +808,55 @@ pub async fn get_git_status( } } + // When there's no upstream yet (e.g. a freshly-created local worktree branch), + // git status doesn't report ahead/behind. We still want to surface unpublished commits. + if tracking.is_none() && !current.trim().is_empty() { + let mut base_candidates: Vec = Vec::new(); + + let origin_head = run_git_with_allowed_exit( + &["symbolic-ref", "-q", "refs/remotes/origin/HEAD"], + &path, + &[1], + ) + .await + .unwrap_or_default(); + + if !origin_head.trim().is_empty() { + base_candidates.push(origin_head.trim().replace("refs/remotes/", "")); + } + + base_candidates.push("origin/main".to_string()); + base_candidates.push("origin/master".to_string()); + base_candidates.push("main".to_string()); + base_candidates.push("master".to_string()); + + let mut selected_base: Option = None; + for candidate in base_candidates { + let verified = run_git_with_allowed_exit( + &["rev-parse", "--verify", &candidate], + &path, + &[1], + ) + .await + .unwrap_or_default(); + + if !verified.trim().is_empty() { + selected_base = Some(candidate); + break; + } + } + + if let Some(base_ref) = selected_base { + let range = format!("{}..HEAD", base_ref); + if let Ok(raw) = run_git(&["rev-list", "--count", &range], &path).await { + if let Ok(count) = raw.trim().parse::() { + ahead = count; + behind = 0; + } + } + } + } + Ok(GitStatus { current, tracking, @@ -1445,13 +1494,45 @@ pub async fn git_push( .await .map_err(|e| e.to_string())?; let remote_name = remote.unwrap_or_else(|| "origin".to_string()); + let explicit_branch = branch + .as_deref() + .map(|value| !value.trim().is_empty()) + .unwrap_or(false); let mut branch_name = branch.unwrap_or_default(); let mut args = vec!["push".to_string(), remote_name.clone()]; if branch_name.is_empty() { branch_name = get_current_branch_name(&root).await.unwrap_or_default(); } + if !branch_name.is_empty() { + // If caller didn't specify a branch and there's no upstream configured yet, + // publish on first push so future pushes/pulls work without extra prompts. + if !explicit_branch { + let remote_key = format!("branch.{}.remote", branch_name); + let merge_key = format!("branch.{}.merge", branch_name); + + let upstream_remote = run_git_with_allowed_exit( + &["config", "--get", &remote_key], + &root, + &[1], + ) + .await + .unwrap_or_default(); + + let upstream_merge = run_git_with_allowed_exit( + &["config", "--get", &merge_key], + &root, + &[1], + ) + .await + .unwrap_or_default(); + + if upstream_remote.trim().is_empty() || upstream_merge.trim().is_empty() { + args.push("--set-upstream".to_string()); + } + } + args.push(branch_name.clone()); } diff --git a/packages/ui/src/components/session/SessionDialogs.tsx b/packages/ui/src/components/session/SessionDialogs.tsx index 0565cef9..81dea29f 100644 --- a/packages/ui/src/components/session/SessionDialogs.tsx +++ b/packages/ui/src/components/session/SessionDialogs.tsx @@ -23,7 +23,7 @@ import { mapWorktreeToMetadata, removeWorktree, } from '@/lib/git/worktreeService'; -import { checkIsGitRepository, ensureOpenChamberIgnored, gitPush } from '@/lib/gitApi'; +import { checkIsGitRepository, ensureOpenChamberIgnored } from '@/lib/gitApi'; import { useSessionStore } from '@/stores/useSessionStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -428,22 +428,7 @@ export const SessionDialogs: React.FC = () => { createBranch: true, }); cleanupMetadata = metadata; - let status = await getWorktreeStatus(metadata.path).catch(() => undefined); - try { - await gitPush(metadata.path, { - remote: 'origin', - branch: normalizedBranch, - options: ['--set-upstream'], - }); - status = await getWorktreeStatus(metadata.path).catch(() => status); - toast.success(`Configured upstream for ${normalizedBranch}`); - } catch (pushError) { - const message = - pushError instanceof Error ? pushError.message : 'Unable to push new worktree branch.'; - toast.warning('Worktree created locally', { - description: renderToastDescription(`Upstream setup failed: ${message}`), - }); - } + const status = await getWorktreeStatus(metadata.path).catch(() => undefined); const createdMetadata = status ? { ...metadata, status } : metadata; const session = await createSession(undefined, metadata.path); diff --git a/packages/ui/src/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx index d557973c..c8360d8b 100644 --- a/packages/ui/src/components/views/git/GitHeader.tsx +++ b/packages/ui/src/components/views/git/GitHeader.tsx @@ -210,17 +210,28 @@ export const GitHeader: React.FC = ({ /> )} - {status.tracking && ( -
- - - {status.ahead} - - - - {status.behind} - -
+ {(Boolean(status.tracking) || status.ahead > 0 || status.behind > 0) && ( + + +
+ + + {status.ahead} + + {Boolean(status.tracking) && ( + + + {status.behind} + + )} +
+
+ + {status.tracking + ? `Upstream: ${status.tracking}` + : 'Unpublished commits (no upstream set yet)'} + +
)} { + 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. + if (!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; + } + } + } + return { current: status.current, - tracking: status.tracking, - ahead: status.ahead, - behind: status.behind, - files: status.files.map(f => ({ + tracking, + ahead, + behind, + files: status.files.map((f) => ({ path: f.path, index: f.index, - working_dir: f.working_dir + working_dir: f.working_dir, })), isClean: status.isClean(), diffStats, @@ -512,22 +559,79 @@ export async function pull(directory, options = {}) { export async function push(directory, options = {}) { const git = simpleGit(normalizeDirectoryPath(directory)); - try { - const result = await git.push( - options.remote || 'origin', - options.branch, - options.options || {} - ); + 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 + ref: result.ref, }; + }; + + const remote = options.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(remote, 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(remote, options.branch, options.options || {}); + return normalizePushResult(result); } catch (error) { - console.error('Failed to push:', error); - throw error; + // Last-resort fallback: retry with upstream if the error suggests it's missing. + if (!looksLikeMissingUpstream(error)) { + console.error('Failed to push:', error); + throw error; + } + + 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(remote, branch, buildUpstreamOptions(options.options)); + return normalizePushResult(result); + } catch (fallbackError) { + console.error('Failed to push (including upstream fallback):', fallbackError); + throw fallbackError; + } } }