Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/staged/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2193,6 +2193,7 @@ pub fn run() {
// Timeline
timeline::get_branch_timeline,
timeline::refresh_branch_git_state,
timeline::list_parent_branch_commits,
timeline::pull_branch_ff_only,
// Notes
note_commands::create_note,
Expand Down
112 changes: 112 additions & 0 deletions apps/staged/src-tauri/src/timeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,38 @@ struct GitStateUpdatedPayload {
git_state: git::BranchGitState,
}

/// A single commit on the parent branch that hasn't yet been merged into the
/// current branch — returned by `list_parent_branch_commits` for the hover
/// popover on the parent-branch capsule.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ParentBranchCommit {
pub sha: String,
pub short_sha: String,
pub subject: String,
pub author: String,
pub author_email: String,
pub timestamp: i64,
}

fn parse_parent_commit_lines(lines: &[String]) -> Vec<ParentBranchCommit> {
let mut commits = Vec::new();
for line in lines {
let parts: Vec<&str> = line.splitn(6, '|').collect();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use a robust delimiter for parent commit parsing

When an upstream commit subject or author contains | (for example feat: add foo | bar), this parser shifts the remaining fields because git log --format=%H|%h|%s|%an|%ae|%ct does not escape that delimiter; the hover then shows the wrong author/email and usually a 0 timestamp. This affects only commits with that character, but those commit subjects are valid and will make the new preview misleading; use a NUL/record separator or parse a format with unambiguous delimiters.

Useful? React with 👍 / 👎.

if parts.len() >= 6 {
commits.push(ParentBranchCommit {
sha: parts[0].to_string(),
short_sha: parts[1].to_string(),
subject: parts[2].to_string(),
author: parts[3].to_string(),
author_email: parts[4].to_string(),
timestamp: parts[5].parse().unwrap_or(0),
});
}
}
commits
}

/// Parse `%H|%h|%s|%an|%ae|%ct` formatted commit lines into timeline items,
/// looking up DB metadata for session linkage.
fn parse_commit_lines(
Expand Down Expand Up @@ -622,6 +654,86 @@ pub async fn refresh_branch_git_state(
.map_err(|e| format!("Git state refresh task failed: {e}"))?
}

/// Return the commits on the parent branch that aren't yet in this branch's
/// ancestry — i.e. the same `merge-base(HEAD, origin/{base})..origin/{base}`
/// range that `commitsSinceFork` counts. Read-only against locally cached
/// refs; the count's own fetch round-trip is what keeps `origin/{base}`
/// current.
#[tauri::command(rename_all = "camelCase")]
pub async fn list_parent_branch_commits(
store: tauri::State<'_, Mutex<Option<Arc<Store>>>>,
branch_id: String,
) -> Result<Vec<ParentBranchCommit>, String> {
let store = crate::get_store(&store)?;

tauri::async_runtime::spawn_blocking(move || {
let branch = store
.get_branch(&branch_id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("Branch not found: {branch_id}"))?;

let base_ref = git::origin_ref_for_branch(&branch.base_branch);
let format_arg = "--format=%H|%h|%s|%an|%ae|%ct";

if let Some(ref ws_name) = branch.workspace_name {
let repo_subpath = branches::resolve_branch_workspace_subpath(&store, &branch)?;
let range = match branches::run_workspace_git(
ws_name,
repo_subpath.as_deref(),
&["merge-base", &base_ref, "HEAD"],
) {
Ok(mb_output) => format!("{}..{base_ref}", mb_output.trim()),
Err(_) => base_ref.clone(),
};
let output = match branches::run_workspace_git(
ws_name,
repo_subpath.as_deref(),
&["log", "--max-count=26", format_arg, &range],
) {
Ok(o) => o,
Err(_) => return Ok(Vec::new()),
};
let lines: Vec<String> = output
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect();
return Ok(parse_parent_commit_lines(&lines));
}

let workdir = store
.get_workdir_for_branch(&branch_id)
.map_err(|e| e.to_string())?;
let Some(wd) = workdir else {
return Ok(Vec::new());
};
let worktree_path = Path::new(&wd.path);
if !worktree_path.exists() {
return Ok(Vec::new());
}

let range = match git::cli_run_smart(worktree_path, &["merge-base", &base_ref, "HEAD"]) {
Ok(mb_output) => format!("{}..{base_ref}", mb_output.trim()),
Err(_) => base_ref.clone(),
};
let output = match git::cli_run_smart(
worktree_path,
&["log", "--max-count=26", format_arg, &range],
) {
Ok(o) => o,
Err(_) => return Ok(Vec::new()),
};
let lines: Vec<String> = output
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect();
Ok(parse_parent_commit_lines(&lines))
})
.await
.map_err(|e| format!("List parent branch commits task failed: {e}"))?
}

#[tauri::command(rename_all = "camelCase")]
pub async fn pull_branch_ff_only(
store: tauri::State<'_, Mutex<Option<Arc<Store>>>>,
Expand Down
13 changes: 13 additions & 0 deletions apps/staged/src/lib/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,19 @@ export function refreshBranchGitState(
});
}

export interface ParentBranchCommit {
sha: string;
shortSha: string;
subject: string;
author: string;
authorEmail: string;
timestamp: number;
}

export function listParentBranchCommits(branchId: string): Promise<ParentBranchCommit[]> {
return invokeCommand('list_parent_branch_commits', { branchId });
}

export function invalidateProjectBranchTimelines(branchIds: string[]): void {
for (const id of branchIds) {
timelineCache.delete(id);
Expand Down
2 changes: 2 additions & 0 deletions apps/staged/src/lib/features/branches/BranchCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -1308,6 +1308,7 @@
{:else if isLocal && !branch.worktreePath && worktreeError}
<div class="card-header">
<BranchCardHeaderInfo
branchId={branch.id}
branchName={branch.branchName}
{repoLabel}
baseBranch={formatBaseBranch(branch.baseBranch)}
Expand Down Expand Up @@ -1347,6 +1348,7 @@
<Sprout size={14} class="header-icon pr-status-clean" />
{/if}
<BranchCardHeaderInfo
branchId={branch.id}
branchName={branch.branchName}
{repoLabel}
baseBranch={isRemote
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import { AlertTriangle, ChevronRight } from 'lucide-svelte';
import Spinner from '../../shared/Spinner.svelte';
import RepoLabel from '../../shared/RepoLabel.svelte';
import ParentBranchCommitsHover from './ParentBranchCommitsHover.svelte';
import type { ProjectRepo } from '../../types';

interface Props {
branchId?: string;
branchName: string;
repoLabel?: ProjectRepo | null;
baseBranch?: string | null;
Expand All @@ -18,6 +20,7 @@
}

let {
branchId,
branchName,
repoLabel = null,
baseBranch = null,
Expand All @@ -30,16 +33,26 @@
}: Props = $props();
</script>

{#snippet capsule()}
<span class="branch-capsule" title={baseBranch}>
{baseBranch}{#if parentAheadCount > 0}<span
class="ahead-count"
transition:fade={{ duration: 150 }}
>
+{parentAheadCount}</span
>{/if}
</span>
{/snippet}

{#snippet parentPill()}
{#if baseBranch}
<span class="branch-capsule" title={baseBranch}>
{baseBranch}{#if parentAheadCount > 0}<span
class="ahead-count"
transition:fade={{ duration: 150 }}
>
+{parentAheadCount}</span
>{/if}
</span>
{#if parentAheadCount > 0 && branchId}
<ParentBranchCommitsHover {branchId} {baseBranch} count={parentAheadCount}>
{@render capsule()}
</ParentBranchCommitsHover>
{:else}
{@render capsule()}
{/if}
{#if parentAheadCount > 0 && onRebase}
<button
class="rebase-btn"
Expand Down
Loading