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
6 changes: 6 additions & 0 deletions .changeset/silver-toys-swim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@1git/gpuix": minor
"@1git/git": patch
---

Review diverged branches with header confirmation popups and return directly to Changes after integration. Compare either conflict source with the working result in an aligned split diff, with highlighted hunks, expandable context, and clearer file groups and operation status. Reject choosing an indexed version that changed after review. Push remains a separate action.
Binary file added .github/assets/conflict-review.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/assets/sync-review.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 28 additions & 8 deletions apps/gpuix/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1592,9 +1592,15 @@ accessible label. Counts come from the last known remote refs, not live polling.
- Pull/Push reuse authenticated, guarded upstream sync: fetch first, fast-forward
incoming commits only, then push to the exact tracking destination without force,
mirrors, tags, automatic commits or stashes. Incoming changes require a clean tree.
- If both counts are nonzero, the control fetches and opens a review dialog.
It offers Compare and points to Merge or rebase in the tab menu; it never
silently merges, rebases or force-pushes.
- If both counts are nonzero, the control fetches and opens a populated sync review
against the configured upstream. Incoming/Outgoing switch between each side's
commits and changes since their common ancestor. Merge and Rebase each show an
button-anchored confirmation pinned to both reviewed tips; dirty or stale workspaces
remain reviewable with an explanation of what must be resolved before writing.
Escape cancels the confirmation, then leaves the review. Conflicts open Changes
with its existing Continue/Abort controls. Successful integration returns directly
to Changes. Push remains a separate action on the rail using the refreshed count
and exact upstream destination; nothing silently integrates, pushes or force-pushes.
- Without remotes, the control opens the existing right-side Publish repository
form. Without an upstream, it opens Publish branch: explicitly choose a remote
with a single push URL and confirm. Changing HEAD/branch invalidates the choice;
Expand Down Expand Up @@ -2266,11 +2272,24 @@ hot reload cannot replace a loaded addon. This bridge is macOS-only.

### Manual conflict resolution

- Conflicted Changes rows show a red status icon and **Conflict** label. Selecting
one shows the read-only working file, including its conflict markers.
- **Open in editor** uses the same file-type rules and overall **External editor**
- Changes groups unresolved files above other changes, with an amber **Unresolved**
badge. Commit-inclusion controls and the ordinary draft editor are hidden while
reviewing an active operation; the draft is retained for returning to normal work.
- Selecting a conflict opens an aligned split diff: the selected indexed version
on the left, and the unchanged working result, including markers, on the right.
Both columns share vertical scrolling, with syntax highlighting, original line
numbers and highlighted changes. Unchanged gaps start collapsed; clicking a hunk
header or trailing context action expands them from the already-read text.
- Switch **Current** / **Incoming** to compare either source with the same result.
Rebase sources are labelled **Upstream version** and **Replayed commit**. Versions
come from index stages 2/3, not reconstructed conflict-marker text. Missing, binary
or oversized sources show an explanation instead of a partial preview.
- The result header shows the remaining conflict-block count. **Mark resolved** is
disabled while marker lines remain. **Use this version…** requires whole-file
replacement confirmation pinned to the reviewed content hash and indexed blob.
- **Open editor** uses the same file-type rules and overall **External editor**
preference (macOS's default text editor until changed), never a repository-provided shell command.
Save edits there, then **Refresh preview**.
Save edits there, then **Refresh**.
- **Mark resolved** stages only the reviewed file. It rechecks HEAD/branch,
conflict status, regular-file safety, UTF-8 content, remaining marker lines,
and the preview's content hash. It does not commit, continue/abort a merge or
Expand All @@ -2287,7 +2306,8 @@ hot reload cannot replace a loaded addon. This bridge is macOS-only.

### Merge, squash merge, rebase, revert and cherry-pick completion

- Changes shows the active merge/squash merge/rebase/revert/cherry-pick, conflict count, **Continue** and **Abort…**.
- Changes shows a compact amber pause state for unresolved conflicts, **Continue**
and **Abort…**. Raw Git diagnostics are available in **Details…**.
- Continue uses Git's staged resolutions and existing message, retaining hooks
and signing. It never stages files or uses the commit-inclusion checkboxes.
Unresolved conflicts or unstaged tracked edits block continuation.
Expand Down
61 changes: 48 additions & 13 deletions apps/gpuix/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ import { WorkspaceRail } from "./feature/repository-workspace/components/workspa
import { RepositorySyncButton } from "./feature/repository-workspace/components/repository-sync-button";
import type { RepositoryView } from "./feature/repository-workspace/types";
import { RepositoryComparison } from "./feature/repository-comparison/repository-comparison";
import { RepositorySyncReview } from "./feature/repository-comparison/repository-sync-review";
import { RepositoryComparison as ComparisonReader } from "./feature/repository-comparison/lib/repository-comparison";
import { RepositoryPullRequest } from "./feature/repository-pull-request/repository-pull-request";
import { RepositoryPullRequestView } from "./feature/repository-pull-request/repository-pull-request-view";
Expand Down Expand Up @@ -1745,24 +1746,34 @@ function AppContent({
workspace.repository.commonDirectory
)
}
onCompare={(data) => {
onCompare={(data, intent) => {
const repository = workspace.repository;

if (!repository?.head) return;

dispatch({
type: "compare",
id: activeTab.id,
comparison: {
root: repository.root,
base: repository.head,
baseName: repository.branchName ?? "HEAD",
branches: data.branches.branches.map((item) => ({
value: item.fullName,
label: item.name,
oid: item.oid,
})),
},
comparison:
intent === "sync" && repository.branchName
? ComparisonReader.syncRequest(
repository.root,
repository.head,
repository.branchName,
data
)
: {
root: repository.root,
base: repository.head,
baseName: repository.branchName ?? "HEAD",
branches: data.branches.branches.map(
(item) => ({
value: item.fullName,
label: item.name,
oid: item.oid,
})
),
},
});
}}
onPreviewPullRequest={() => {
Expand Down Expand Up @@ -2217,6 +2228,24 @@ function AppContent({
diffSettings={diffSettings}
onDiffSettingsChange={setDiffSettings}
/>
) : activeTab?.comparison?.intent === "sync" && workspace ? (
<RepositorySyncReview
fileTreeScope={activeTab.id}
key={`${activeTab.id}:${activeTab.comparison.base}:${activeTab.comparison.incoming}:${activeTab.comparison.branches.map((branch) => branch.oid).join(":")}`}
request={activeTab.comparison}
state={workspace}
onClose={() => dispatch({ type: "compare", id: activeTab.id })}
onIntegrate={(choice) =>
mutations.changeBranch(activeTab.id, choice)
}
readComparison={readComparison}
readHistory={readHistory}
readCommitFiles={readCommitFiles}
readDiff={readDiff}
previewCache={previewCache}
diffSettings={diffSettings}
onDiffSettingsChange={setDiffSettings}
/>
) : activeTab?.comparison ? (
<RepositoryComparison
fileTreeScope={activeTab.id}
Expand Down Expand Up @@ -2344,8 +2373,14 @@ function AppContent({
}
statusError={workspace.error}
readDiff={readDiff}
onResolve={(hash, side) =>
mutations.resolve(activeTab.id, comparison.path, hash, side)
onResolve={(hash, side, expectedSideOid) =>
mutations.resolve(
activeTab.id,
comparison.path,
hash,
side,
expectedSideOid
)
}
openConflictEditor={launchConflict}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,93 @@ import { type BranchComparison, type GitResult } from "@1git/git";
import { GitRepository } from "../../machines/repository-access";
import { RepositoryHistory } from "../../repository-workspace/lib/repository-history";
import type { WorkspaceEntry } from "../../repository-workspace/types";
import type { WorkspaceBranches } from "../../workspace-tabs/lib/repository-branches";

/** A comparison captures references when opened, without changing the workspace. */
export interface ComparisonRequest {
interface ComparisonBranches {
readonly root: string;
readonly base: string;
readonly baseName: string;
readonly incoming?: string;
readonly intent?: "pull-request";
readonly branches: readonly {
readonly value: string;
readonly label: string;
readonly oid: string;
}[];
}

/** Sync reviews keep both branch tips and their fetched counts together. */
export type ComparisonRequest = ComparisonBranches &
(
| { readonly intent?: "pull-request" }
| {
readonly intent: "sync";
readonly incoming: string;
readonly ahead: number;
readonly behind: number;
readonly remote: string;
readonly remoteRef: string;
}
);

export type SyncComparisonRequest = Extract<
ComparisonRequest,
{ intent: "sync" }
>;

export class RepositoryComparison {
private constructor() {}

/** Select the configured tracking ref from the fetched collection without guessing a remote. */
static syncRequest(
root: string,
head: string,
branchName: string,
data: WorkspaceBranches
): SyncComparisonRequest {
const branch = data.branches.branches.find(
(item) => item.type === "local" && item.name === branchName
);

if (data.branches.current !== branchName || branch?.oid !== head)
throw new Error(
"The branch changed. Go back and sync again to refresh the review."
);

const upstream = branch.upstream
? data.branches.branches.find(
(item) => item.type === "remote" && item.name === branch.upstream
)
: undefined;

if (
!upstream ||
!branch.upstreamRemote ||
branch.upstreamRemote === "." ||
!branch.upstreamRef
)
throw new Error(
"The upstream branch is unavailable. Check Remotes, then sync again."
);

return {
root,
base: head,
baseName: branchName,
intent: "sync",
incoming: upstream.fullName,
ahead: branch.ahead,
behind: branch.behind,
remote: branch.upstreamRemote,
remoteRef: branch.upstreamRef,
branches: [branch, upstream].map((item) => ({
value: item.fullName,
label: item.name,
oid: item.oid,
})),
};
}

static async read(
root: string,
base: string,
Expand Down
Loading
Loading