Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3c8421d
fix(windows): add Git Log reference actions
puppyben1 Aug 28, 2026
7520b1e
fix(macos): complete Git Log reference actions
puppyben1 Aug 28, 2026
a88faf7
style(windows): clarify Git refresh icon
puppyben1 Aug 28, 2026
a242835
Merge branch 'preview' into codex/fix-issue-293-windows
1lck Aug 28, 2026
1c6135e
Merge branch 'preview' into codex/fix-issue-293-windows
1lck Aug 28, 2026
2b7a04f
fix(git): validate remote names and pull preflight
puppyben1 Aug 30, 2026
d4f3029
fix(windows): surface reference diff failures
puppyben1 Aug 30, 2026
ba4e282
fix(windows): include untracked files in reference diff
puppyben1 Aug 30, 2026
92aaee4
fix(windows): restore stashed changes after remote pull
puppyben1 Aug 30, 2026
cceb2b8
fix(macos): preflight remote pull changes
puppyben1 Aug 30, 2026
a33b863
fix(macos): preserve local changes during remote pull
puppyben1 Aug 30, 2026
587188c
refactor(core): isolate remote mutation parsing
puppyben1 Aug 30, 2026
f8a1dce
refactor(core): remove duplicate remote parser
puppyben1 Aug 30, 2026
11952da
test(core): pull nested remote reference from bare repository
puppyben1 Aug 30, 2026
0445457
test(core): use nested remote in mutation fixture
puppyben1 Aug 30, 2026
60354db
refactor(core): move checkout rebase mutation
puppyben1 Aug 30, 2026
56ff7a0
fix(macos): correct async shelf restore condition
puppyben1 Aug 30, 2026
1fc447f
test(windows): update reference diff translation expectation
puppyben1 Aug 30, 2026
9103277
Merge branch 'preview' into codex/fix-issue-293-windows
1lck Aug 30, 2026
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
23 changes: 23 additions & 0 deletions macos/Sources/Lithe/Core/Rust/RustGitOperations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,33 @@ struct RustGitOperations: GitOperations, Sendable {
write(at: rootURL, operation: "rebase", reference: reference.fullName)
}

func checkoutAndRebase(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? {
write(
at: rootURL,
operation: "checkoutAndRebase",
reference: reference.fullName,
referenceKind: reference.kind
)
}

func updateCurrentBranch(at rootURL: URL, strategy: GitPullStrategy = .ffOnly) -> GitProcessResult? {
write(at: rootURL, operation: "pull", mode: strategy.rawValue)
}

func pullRemoteReference(
_ reference: GitReference,
strategy: GitPullStrategy,
at rootURL: URL
) -> GitProcessResult? {
write(
at: rootURL,
operation: "pull",
reference: reference.fullName,
referenceKind: reference.kind,
mode: strategy.rawValue
)
}

/// Staged files still containing conflict markers.
func conflictMarkerPaths(at rootURL: URL) -> [String] {
core.gitConflictMarkerPaths(at: rootURL)?.paths ?? []
Expand Down
10 changes: 10 additions & 0 deletions macos/Sources/Lithe/Models/AppModel/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1738,6 +1738,16 @@ final class AppModel: ObservableObject, Identifiable {
await gitFeature.rebaseCurrentBranch(onto: reference)
}

func checkoutAndRebase(_ reference: GitReference) async {
guard let gitFeature = await activateGitModule() else { return }
await gitFeature.checkoutAndRebase(reference)
}

func pullRemoteReference(_ reference: GitReference, strategy: GitPullStrategy) async {
guard let gitFeature = await activateGitModule() else { return }
await gitFeature.pullRemoteReference(reference, strategy: strategy)
}

func updateCurrentBranch(_ reference: GitReference) async {
guard let gitFeature = await activateGitModule() else { return }
await gitFeature.updateCurrentBranch(reference)
Expand Down
93 changes: 77 additions & 16 deletions macos/Sources/Lithe/Views/Git/GitLogView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,12 @@ struct GitLogView: View {
await model.mergeBranch(operation.reference)
case .rebase:
await model.rebaseCurrentBranch(onto: operation.reference)
case .checkoutAndRebase:
await model.checkoutAndRebase(operation.reference)
case .pullRebase:
await model.pullRemoteReference(operation.reference, strategy: .rebase)
case .pullMerge:
await model.pullRemoteReference(operation.reference, strategy: .merge)
}
}
}
Expand Down Expand Up @@ -824,6 +830,12 @@ struct GitLogView: View {
Task { await model.showComparisonWithWorkingTree(for: reference) }
}

if let currentReference, currentReference.id != reference.id {
Button("Compare with Current Branch") {
Task { await model.showComparison(from: reference, to: currentReference) }
}
}

if let source = comparisonSourceReference, source.id != reference.id {
Button("Compare '\(source.shortName)' with '\(reference.shortName)'") {
comparisonSourceReference = nil
Expand All @@ -835,39 +847,73 @@ struct GitLogView: View {
}
}

if reference.kind == .local {
if !reference.isCurrent {
Divider()

if !reference.isCurrent {
Button("Checkout") {
Task { await model.checkoutReference(reference) }
}
.disabled(model.isPerformingBranchOperation)
}

Button("Update") {
Task { await model.updateCurrentBranch(reference) }
}
.disabled(!reference.isCurrent || model.isPerformingBranchOperation)

Button("Push…") {
pendingPushReference = reference
Button("Checkout") {
Task { await model.checkoutReference(reference) }
}
.disabled(model.isPerformingBranchOperation)

if !reference.isCurrent {
if reference.kind != .tag {
Button("Checkout and Rebase onto Current Branch") {
pendingBranchOperation = GitBranchOperationRequest(
kind: .checkoutAndRebase,
reference: reference
)
}
.disabled(model.isPerformingBranchOperation)

Button("Merge into Current Branch") {
pendingBranchOperation = GitBranchOperationRequest(
kind: .merge,
reference: reference
)
}
.disabled(model.isPerformingBranchOperation)
Button("Rebase Current Branch onto…") {
pendingBranchOperation = GitBranchOperationRequest(
kind: .rebase,
reference: reference
)
}
.disabled(model.isPerformingBranchOperation)
}
}

if reference.kind == .remote {
Divider()

Button("Pull with Rebase") {
pendingBranchOperation = GitBranchOperationRequest(
kind: .pullRebase,
reference: reference
)
}
.disabled(model.isPerformingBranchOperation)
Button("Pull with Merge") {
pendingBranchOperation = GitBranchOperationRequest(
kind: .pullMerge,
reference: reference
)
}
.disabled(model.isPerformingBranchOperation)
}

if reference.kind == .local {
Divider()

Button("Update") {
Task { await model.updateCurrentBranch(reference) }
}
.disabled(!reference.isCurrent || model.isPerformingBranchOperation)

Button("Push…") {
pendingPushReference = reference
}
.disabled(model.isPerformingBranchOperation)

if !reference.isCurrent {
Button("Delete Branch", role: .destructive) {
pendingBranchOperation = GitBranchOperationRequest(
kind: .delete,
Expand Down Expand Up @@ -1899,12 +1945,18 @@ private enum GitBranchOperationKind {
case delete
case merge
case rebase
case checkoutAndRebase
case pullRebase
case pullMerge

var title: String {
switch self {
case .delete: "Delete branch?"
case .merge: "Merge branch?"
case .rebase: "Rebase branch?"
case .checkoutAndRebase: "Checkout and rebase branch?"
case .pullRebase: "Pull remote branch with rebase?"
case .pullMerge: "Pull remote branch with merge?"
}
}

Expand All @@ -1913,6 +1965,9 @@ private enum GitBranchOperationKind {
case .delete: "Delete"
case .merge: "Merge"
case .rebase: "Rebase"
case .checkoutAndRebase: "Checkout and Rebase"
case .pullRebase: "Pull with Rebase"
case .pullMerge: "Pull with Merge"
}
}

Expand All @@ -1924,6 +1979,12 @@ private enum GitBranchOperationKind {
return "Merge \(reference.shortName) into the current branch. Conflicts may require terminal resolution."
case .rebase:
return "Replay the current branch onto \(reference.shortName). Conflicts may require terminal resolution."
case .checkoutAndRebase:
return "Checkout \(reference.shortName), then replay it onto the branch that is current now."
case .pullRebase:
return "Pull \(reference.shortName) into the current branch and replay local commits."
case .pullMerge:
return "Pull \(reference.shortName) into the current branch with a merge."
}
}
}
Expand Down
86 changes: 86 additions & 0 deletions macos/Sources/LitheGitModule/Application/GitFeatureModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1727,6 +1727,92 @@ package final class GitFeatureModel: ObservableObject {
await startIntegration(.reference(reference), operation: .rebase)
}

package func checkoutAndRebase(_ reference: GitReference) async {
guard let gitRepositoryRoot, reference.kind != .tag, !reference.isCurrent else { return }
isPerformingBranchOperation = true
let result = await withGitOperation {
await service.checkoutAndRebase(reference, at: gitRepositoryRoot)
}
isPerformingBranchOperation = false
await reportBranchOperation(
result,
success: "Checked out \(reference.shortName) and rebased it onto the previous branch"
)
}

package func pullRemoteReference(
_ reference: GitReference,
strategy: GitPullStrategy
) async {
guard let gitRepositoryRoot, reference.kind == .remote else { return }
isPerformingBranchOperation = true
let preflight = await service.integrationPreflight(
for: .reference(reference),
operation: strategy == .rebase ? .rebase : .merge,
at: gitRepositoryRoot
)
if let preflight, !preflight.isClear {
switch selectedSaveChangesPolicy {
case .stash:
let message = "Lithe auto-stash before pull"
let stashed = await recordingGitCommand {
await service.stash(message: message, includeUntracked: true, at: gitRepositoryRoot)
}
guard stashed.succeeded else {
isPerformingBranchOperation = false
notify?(trimmedMessage(stashed))
return
}
let result = await withGitOperation {
await service.pullRemoteReference(reference, strategy: strategy, at: gitRepositoryRoot)
}
isPerformingBranchOperation = false
await refreshGit()
if gitOperationState?.hasConflicts == true {
if let stash = gitStashes.first(where: { $0.message.contains(message) }) {
deferredSavedChanges = GitDeferredSavedChanges(stashReference: stash.reference, operationTitle: "pull")
}
notify?("拉取产生冲突,改动已保留在暂存中")
return
}
if let stash = gitStashes.first(where: { $0.message.contains(message) }) {
let restored = await service.popStash(stash, at: gitRepositoryRoot)
if !restored.succeeded { notify?("恢复本地改动失败:\(trimmedMessage(restored))") }
}
await reportBranchOperation(result, success: strategy == .rebase ? "从远程分支变基拉取完成" : "从远程分支合并拉取完成")
return
case .shelve:
let capture = await captureAndCleanShelf(message: "Lithe shelf before pull", at: gitRepositoryRoot)
guard case .saved(let shelf) = capture else {
isPerformingBranchOperation = false
if case .failed(let message) = capture { notify?(message) }
return
}
let result = await withGitOperation {
await service.pullRemoteReference(reference, strategy: strategy, at: gitRepositoryRoot)
}
isPerformingBranchOperation = false
await refreshGit()
if gitOperationState?.hasConflicts == true {
deferredSavedChanges = GitDeferredSavedChanges(shelfID: shelf.id, operationTitle: "pull")
notify?("拉取产生冲突,改动已保留在搁置中")
return
}
if !(await restoreShelf(shelf, at: gitRepositoryRoot)) {
notify?("恢复搁置改动失败")
}
await reportBranchOperation(result, success: strategy == .rebase ? "从远程分支变基拉取完成" : "从远程分支合并拉取完成")
return
}
}
let result = await withGitOperation {
await service.pullRemoteReference(reference, strategy: strategy, at: gitRepositoryRoot)
Comment thread
1lck marked this conversation as resolved.
}
isPerformingBranchOperation = false
let verb = strategy == .rebase ? "Rebased from" : "Merged from"
await reportBranchOperation(result, success: "\(verb) \(reference.shortName)")
}

/// Checks whether uncommitted changes would stop the operation before running
/// it, so the user gets a choice instead of Git's localized refusal.
private func startIntegration(
Expand Down
20 changes: 20 additions & 0 deletions macos/Sources/LitheGitModule/Services/GitService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,13 @@ package protocol GitOperations: Sendable {
func deleteBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult?
func mergeBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult?
func rebaseCurrentBranch(onto reference: GitReference, at rootURL: URL) -> GitProcessResult?
func checkoutAndRebase(_ reference: GitReference, at rootURL: URL) -> GitProcessResult?
func updateCurrentBranch(at rootURL: URL, strategy: GitPullStrategy) -> GitProcessResult?
func pullRemoteReference(
_ reference: GitReference,
strategy: GitPullStrategy,
at rootURL: URL
) -> GitProcessResult?
func pullPreflight(at rootURL: URL) -> GitPullPreflightState?
func conflictMarkerPaths(at rootURL: URL) -> [String]
func integrationPreflight(
Expand Down Expand Up @@ -507,13 +513,27 @@ package struct GitService: Sendable {
await command(at: repositoryRoot) { $0.rebaseCurrentBranch(onto: reference, at: repositoryRoot) }
}

func checkoutAndRebase(_ reference: GitReference, at repositoryRoot: URL) async -> CommandResult {
await command(at: repositoryRoot) { $0.checkoutAndRebase(reference, at: repositoryRoot) }
}

func updateCurrentBranch(
at repositoryRoot: URL,
strategy: GitPullStrategy = .ffOnly
) async -> CommandResult {
await command(at: repositoryRoot) { $0.updateCurrentBranch(at: repositoryRoot, strategy: strategy) }
}

func pullRemoteReference(
_ reference: GitReference,
strategy: GitPullStrategy,
at repositoryRoot: URL
) async -> CommandResult {
await command(at: repositoryRoot) {
$0.pullRemoteReference(reference, strategy: strategy, at: repositoryRoot)
}
}

func pullPreflight(at repositoryRoot: URL) async -> GitPullPreflightState? {
await read { $0.pullPreflight(at: repositoryRoot) }
}
Expand Down
Loading
Loading