feat: enhance git operations with unpublished commits detection and upstream setup on first push
This commit is contained in:
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [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).
|
- 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).
|
||||||
|
|||||||
Generated
+1
-1
@@ -2847,7 +2847,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openchamber-desktop"
|
name = "openchamber-desktop"
|
||||||
version = "1.3.8"
|
version = "1.3.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
@@ -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<String> = 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<String> = 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::<i32>() {
|
||||||
|
ahead = count;
|
||||||
|
behind = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(GitStatus {
|
Ok(GitStatus {
|
||||||
current,
|
current,
|
||||||
tracking,
|
tracking,
|
||||||
@@ -1445,13 +1494,45 @@ pub async fn git_push(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let remote_name = remote.unwrap_or_else(|| "origin".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 branch_name = branch.unwrap_or_default();
|
||||||
|
|
||||||
let mut args = vec!["push".to_string(), remote_name.clone()];
|
let mut args = vec!["push".to_string(), remote_name.clone()];
|
||||||
if branch_name.is_empty() {
|
if branch_name.is_empty() {
|
||||||
branch_name = get_current_branch_name(&root).await.unwrap_or_default();
|
branch_name = get_current_branch_name(&root).await.unwrap_or_default();
|
||||||
}
|
}
|
||||||
|
|
||||||
if !branch_name.is_empty() {
|
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());
|
args.push(branch_name.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
mapWorktreeToMetadata,
|
mapWorktreeToMetadata,
|
||||||
removeWorktree,
|
removeWorktree,
|
||||||
} from '@/lib/git/worktreeService';
|
} from '@/lib/git/worktreeService';
|
||||||
import { checkIsGitRepository, ensureOpenChamberIgnored, gitPush } from '@/lib/gitApi';
|
import { checkIsGitRepository, ensureOpenChamberIgnored } from '@/lib/gitApi';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
@@ -428,22 +428,7 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
createBranch: true,
|
createBranch: true,
|
||||||
});
|
});
|
||||||
cleanupMetadata = metadata;
|
cleanupMetadata = metadata;
|
||||||
let status = await getWorktreeStatus(metadata.path).catch(() => undefined);
|
const 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 createdMetadata = status ? { ...metadata, status } : metadata;
|
const createdMetadata = status ? { ...metadata, status } : metadata;
|
||||||
|
|
||||||
const session = await createSession(undefined, metadata.path);
|
const session = await createSession(undefined, metadata.path);
|
||||||
|
|||||||
@@ -210,17 +210,28 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status.tracking && (
|
{(Boolean(status.tracking) || status.ahead > 0 || status.behind > 0) && (
|
||||||
<div className="flex items-center gap-2 px-1.5 typography-meta text-muted-foreground">
|
<Tooltip delayDuration={800}>
|
||||||
<span className="flex items-center gap-0.5">
|
<TooltipTrigger asChild>
|
||||||
<RiArrowUpLine className="size-3.5 text-primary/70" />
|
<div className="flex items-center gap-2 px-1.5 typography-meta text-muted-foreground">
|
||||||
<span className="font-semibold text-foreground">{status.ahead}</span>
|
<span className="flex items-center gap-0.5">
|
||||||
</span>
|
<RiArrowUpLine className="size-3.5 text-primary/70" />
|
||||||
<span className="flex items-center gap-0.5">
|
<span className="font-semibold text-foreground">{status.ahead}</span>
|
||||||
<RiArrowDownLine className="size-3.5 text-primary/70" />
|
</span>
|
||||||
<span className="font-semibold text-foreground">{status.behind}</span>
|
{Boolean(status.tracking) && (
|
||||||
</span>
|
<span className="flex items-center gap-0.5">
|
||||||
</div>
|
<RiArrowDownLine className="size-3.5 text-primary/70" />
|
||||||
|
<span className="font-semibold text-foreground">{status.behind}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent sideOffset={8}>
|
||||||
|
{status.tracking
|
||||||
|
? `Upstream: ${status.tracking}`
|
||||||
|
: 'Unpublished commits (no upstream set yet)'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SyncActions
|
<SyncActions
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 425 KiB |
@@ -271,15 +271,62 @@ export async function getStatus(directory) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
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 {
|
return {
|
||||||
current: status.current,
|
current: status.current,
|
||||||
tracking: status.tracking,
|
tracking,
|
||||||
ahead: status.ahead,
|
ahead,
|
||||||
behind: status.behind,
|
behind,
|
||||||
files: status.files.map(f => ({
|
files: status.files.map((f) => ({
|
||||||
path: f.path,
|
path: f.path,
|
||||||
index: f.index,
|
index: f.index,
|
||||||
working_dir: f.working_dir
|
working_dir: f.working_dir,
|
||||||
})),
|
})),
|
||||||
isClean: status.isClean(),
|
isClean: status.isClean(),
|
||||||
diffStats,
|
diffStats,
|
||||||
@@ -512,22 +559,79 @@ export async function pull(directory, options = {}) {
|
|||||||
export async function push(directory, options = {}) {
|
export async function push(directory, options = {}) {
|
||||||
const git = simpleGit(normalizeDirectoryPath(directory));
|
const git = simpleGit(normalizeDirectoryPath(directory));
|
||||||
|
|
||||||
try {
|
const buildUpstreamOptions = (raw) => {
|
||||||
const result = await git.push(
|
if (Array.isArray(raw)) {
|
||||||
options.remote || 'origin',
|
return raw.includes('--set-upstream') ? raw : [...raw, '--set-upstream'];
|
||||||
options.branch,
|
}
|
||||||
options.options || {}
|
|
||||||
);
|
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
pushed: result.pushed,
|
pushed: result.pushed,
|
||||||
repo: result.repo,
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to push:', error);
|
// Last-resort fallback: retry with upstream if the error suggests it's missing.
|
||||||
throw error;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user