From 0a034dd1c0dc529d76410588b450749bf2b86b55 Mon Sep 17 00:00:00 2001 From: Wz58luck <2514832692@qq.com> Date: Wed, 2 Sep 2026 10:27:03 +0800 Subject: [PATCH 1/4] feat(git): rebuild PR 354 on latest preview --- .../Lithe/Core/Rust/RustCoreBridge.swift | 17 + .../Lithe/Core/Rust/RustGitOperations.swift | 33 + .../AppModel/AppModel+FeatureState.swift | 6 + .../Lithe/Models/AppModel/AppModel.swift | 31 + .../Lithe/Views/Git/GitGraphView.swift | 2 + .../Sources/Lithe/Views/Git/GitLogView.swift | 411 +++++++--- .../Application/GitFeatureModel.swift | 137 +++- .../LitheGitModule/Models/GitModels.swift | 54 +- .../LitheGitModule/Ports/GitPorts.swift | 56 ++ .../LitheGitModule/Services/GitService.swift | 28 + .../LitheGitModuleTests/GitModuleTests.swift | 709 +++++++++++++++++- rust/lithe-core/src/git/mod.rs | 509 ++++++++++++- rust/lithe-core/src/protocol/contracts.rs | 3 + rust/lithe-core/src/tests/git.rs | 496 ++++++++++++ shared/contracts/rust-core-api.md | 36 +- shared/fixtures/git/history-response-v1.json | 4 + shared/fixtures/git/tag-names.json | 35 + shared/fixtures/git/write.json | 53 ++ .../features/git/api/git-branches-api.test.ts | 2 + .../git/api/git-integration-api.test.ts | 5 + .../src/features/git/api/git-push-api.test.ts | 1 + .../features/git/api/git-remotes-api.test.ts | 1 + .../git/api/git-worktrees-api.test.ts | 1 + .../tauri/src/features/git/types/git.types.ts | 1 + .../git/utils/git-reference-actions.test.ts | 1 + .../git/utils/git-reference-tree.test.ts | 2 + .../core-result-adapter.history.test.ts | 19 + .../tauri/src/platform/core-result-adapter.ts | 2 + 28 files changed, 2556 insertions(+), 99 deletions(-) create mode 100644 shared/fixtures/git/tag-names.json diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 8e3247df..491be4a2 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -818,6 +818,18 @@ struct RustCoreBridge: Sendable { let conflictedPaths: [String] } + struct TagDeletion: Decodable, Sendable { + let name: String + let deletedTarget: String + let kind: GitTagKind + let message: String? + } + + struct BranchDeletion: Decodable, Sendable { + let name: String + let deletedTarget: String + } + struct Warning: Decodable, Sendable { let code: String let message: String @@ -832,6 +844,8 @@ struct RustCoreBridge: Sendable { let invocations: [Invocation]? let operationError: OperationError? let stashRestore: StashRestore? + let tagDeletion: TagDeletion? + let branchDeletion: BranchDeletion? let warnings: [Warning]? } @@ -906,6 +920,7 @@ struct RustCoreBridge: Sendable { let fullName: String let shortName: String let kind: String + let peelsToCommit: Bool let isCurrent: Bool let upstreamShortName: String? } @@ -936,6 +951,7 @@ struct RustCoreBridge: Sendable { fullName: reference.fullName, shortName: reference.shortName, kind: kind, + peelsToCommit: reference.peelsToCommit, isCurrent: reference.isCurrent, upstreamShortName: reference.upstreamShortName ) @@ -946,6 +962,7 @@ struct RustCoreBridge: Sendable { fullName: reference.fullName, shortName: reference.shortName, kind: kind, + peelsToCommit: reference.peelsToCommit, isCurrent: reference.isCurrent, upstreamShortName: reference.upstreamShortName ) diff --git a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift index 69cc7c35..77d92e37 100644 --- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift @@ -31,6 +31,20 @@ struct RustGitOperations: GitOperations, Sendable { conflictedPaths: $0.conflictedPaths ) }, + tagDeletion: response.tagDeletion.map { + GitTagDeletion( + name: $0.name, + deletedTarget: $0.deletedTarget, + kind: $0.kind, + message: $0.message + ) + }, + branchDeletion: response.branchDeletion.map { + GitBranchDeletion( + name: $0.name, + deletedTarget: $0.deletedTarget + ) + }, warnings: response.warnings?.map { GitOperationWarning(code: $0.code, message: $0.message, details: $0.details) } ?? [] @@ -319,6 +333,25 @@ struct RustGitOperations: GitOperations, Sendable { write(at: rootURL, operation: "stageAll") } + func createTag( + named name: String, + at revision: String, + message: String?, + rootURL: URL + ) -> GitProcessResult? { + write( + at: rootURL, + operation: "createTag", + revision: revision, + name: name, + message: message + ) + } + + func deleteTag(named name: String, rootURL: URL) -> GitProcessResult? { + write(at: rootURL, operation: "deleteTag", name: name) + } + func snapshot(at rootURL: URL) -> GitSnapshot? { core.gitStatus(at: rootURL)?.makeSnapshot(at: rootURL) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index c41c41a4..2247ea02 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -213,6 +213,12 @@ extension AppModel { var requestedStashReference: String? { gitFeatureIfActive?.requestedStashReference } + var recentlyDeletedTag: GitTagDeletion? { + gitFeatureIfActive?.recentlyDeletedTag + } + var recentlyDeletedBranch: GitBranchDeletion? { + gitFeatureIfActive?.recentlyDeletedBranch + } var isCommitting: Bool { gitFeatureIfActive?.isCommitting ?? false } var gitBlameLines: [URL: [GitBlameLine]] { gitFeatureIfActive?.gitBlameLines ?? [:] } var gitReferences: [GitReference] { gitFeatureIfActive?.gitReferences ?? [] } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 3514c3a6..2c015457 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -1645,6 +1645,37 @@ final class AppModel: ObservableObject, Identifiable { await gitFeature.deleteBranch(reference) } + func restoreRecentlyDeletedBranch() async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.restoreRecentlyDeletedBranch() + } + + func dismissDeletedBranchBanner() { + gitFeatureIfActive?.dismissDeletedBranchBanner() + } + + /// Returns nil on success, otherwise the error message a tag dialog + /// should show where the user typed. + @discardableResult + func createTag(at commit: GitCommit, name: String, message: String) async -> String? { + guard let gitFeature = await activateGitModule() else { return "No Git repository is open" } + return await gitFeature.createTag(at: commit, name: name, message: message) + } + + func deleteTag(_ reference: GitReference) async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.deleteTag(reference) + } + + func restoreRecentlyDeletedTag() async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.restoreRecentlyDeletedTag() + } + + func dismissDeletedTagBanner() { + gitFeatureIfActive?.dismissDeletedTagBanner() + } + func mergeBranch(_ reference: GitReference) async { guard let gitFeature = await activateGitModule() else { return } await gitFeature.mergeBranch(reference) diff --git a/macos/Sources/Lithe/Views/Git/GitGraphView.swift b/macos/Sources/Lithe/Views/Git/GitGraphView.swift index 55769df1..01c9bbbf 100644 --- a/macos/Sources/Lithe/Views/Git/GitGraphView.swift +++ b/macos/Sources/Lithe/Views/Git/GitGraphView.swift @@ -11,6 +11,7 @@ struct GitGraphRowActions { let onCherryPick: (GitCommit) -> Void let onRevert: (GitCommit) -> Void let onReset: (GitCommit) -> Void + let onCreateTag: (GitCommit) -> Void } struct GitGraphView: View { @@ -136,6 +137,7 @@ private struct GitGraphRowView: View, Equatable { NSPasteboard.general.setString(row.commit.shortHash, forType: .string) } Divider() + Button("New Tag…") { actions.onCreateTag(row.commit) } Button("Cherry-pick Commit…") { actions.onCherryPick(row.commit) } Button("Revert Commit…") { actions.onRevert(row.commit) } Button("Reset Current Branch to Here…") { actions.onReset(row.commit) } diff --git a/macos/Sources/Lithe/Views/Git/GitLogView.swift b/macos/Sources/Lithe/Views/Git/GitLogView.swift index 99ee19b7..3e7efea1 100644 --- a/macos/Sources/Lithe/Views/Git/GitLogView.swift +++ b/macos/Sources/Lithe/Views/Git/GitLogView.swift @@ -18,9 +18,11 @@ struct GitLogView: View { @State private var filesPaneHeight: CGFloat? @State private var filesPaneDragStart: CGFloat = 0 @State private var branchDialogRequest: GitBranchDialogRequest? + @State private var tagDialogRequest: GitTagDialogRequest? @State private var pendingPushReference: GitReference? @State private var pendingCommitOperation: GitCommitOperationRequest? @State private var pendingBranchOperation: GitBranchOperationRequest? + @State private var pendingTagDeletion: GitReference? @State private var comparisonSourceReference: GitReference? @State private var showCommitDecorations = false @State private var selectedGitToolTab = GitToolTab.log @@ -69,92 +71,7 @@ struct GitLogView: View { var body: some View { VStack(spacing: 0) { toolWindowHeader - if selectedGitToolTab == .log { - primaryActionBar - - GeometryReader { geometry in - let minimumReferencePaneWidth: CGFloat = 220 - let minimumCommitPaneWidth: CGFloat = 340 - let minimumDetailPaneWidth: CGFloat = 280 - let availablePaneWidth = max( - 0, - geometry.size.width - (SplitHandleView.thickness * 2) - ) - let maximumDetailPaneWidth = max( - minimumDetailPaneWidth, - min(520, availablePaneWidth - minimumReferencePaneWidth - minimumCommitPaneWidth) - ) - let resolvedDetailPaneWidth = constrained( - detailPaneWidth, - minimum: minimumDetailPaneWidth, - maximum: maximumDetailPaneWidth - ) - let maximumReferencePaneWidth = max( - minimumReferencePaneWidth, - min(480, availablePaneWidth - resolvedDetailPaneWidth - minimumCommitPaneWidth) - ) - let resolvedReferencePaneWidth = constrained( - referencePaneWidth, - minimum: minimumReferencePaneWidth, - maximum: maximumReferencePaneWidth - ) - - HStack(spacing: 0) { - referencePane - .frame(width: resolvedReferencePaneWidth) - - SplitHandleView( - axis: .horizontal, - onDragStarted: { - referencePaneDragStart = resolvedReferencePaneWidth - }, - onDragChanged: { translation in - referencePaneWidth = constrained( - referencePaneDragStart + translation, - minimum: minimumReferencePaneWidth, - maximum: maximumReferencePaneWidth - ) - }, - onDragEnded: { translation in - referencePaneWidth = constrained( - referencePaneDragStart + translation, - minimum: minimumReferencePaneWidth, - maximum: maximumReferencePaneWidth - ) - } - ) - - commitPane - .frame(minWidth: minimumCommitPaneWidth, maxWidth: .infinity) - - SplitHandleView( - axis: .horizontal, - onDragStarted: { - detailPaneDragStart = resolvedDetailPaneWidth - }, - onDragChanged: { translation in - detailPaneWidth = constrained( - detailPaneDragStart - translation, - minimum: minimumDetailPaneWidth, - maximum: maximumDetailPaneWidth - ) - }, - onDragEnded: { translation in - detailPaneWidth = constrained( - detailPaneDragStart - translation, - minimum: minimumDetailPaneWidth, - maximum: maximumDetailPaneWidth - ) - } - ) - - detailPane - .frame(width: resolvedDetailPaneWidth) - } - } - } else { - gitConsolePane - } + primaryContent } .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.sidebar) .task(id: model.gitCommits) { @@ -302,6 +219,170 @@ struct GitLogView: View { Text(operation.kind.message(for: operation.reference)) } } + .modifier(GitTagDialogsModifier( + tagDialogRequest: $tagDialogRequest, + pendingTagDeletion: $pendingTagDeletion + )) + } + + /// The tab split lives outside `body` because the main expression is + /// already close to the type-checker limit. + @ViewBuilder + private var primaryContent: some View { + if selectedGitToolTab == .log { + logTabContent + } else { + gitConsolePane + } + } + + private var logTabContent: some View { + Group { + primaryActionBar + if let deletedBranch = model.recentlyDeletedBranch { + deletedReferenceBanner( + icon: "arrow.triangle.branch", + message: "Deleted branch '\(deletedBranch.name)'", + onRestore: { await model.restoreRecentlyDeletedBranch() }, + onDismiss: { model.dismissDeletedBranchBanner() } + ) + } + if let deletedTag = model.recentlyDeletedTag { + deletedReferenceBanner( + icon: "tag", + message: "Deleted tag '\(deletedTag.name)'", + onRestore: { await model.restoreRecentlyDeletedTag() }, + onDismiss: { model.dismissDeletedTagBanner() } + ) + } + logPanes + } + } + + private var logPanes: some View { + GeometryReader { geometry in + let minimumReferencePaneWidth: CGFloat = 220 + let minimumCommitPaneWidth: CGFloat = 340 + let minimumDetailPaneWidth: CGFloat = 280 + let availablePaneWidth = max( + 0, + geometry.size.width - (SplitHandleView.thickness * 2) + ) + let maximumDetailPaneWidth = max( + minimumDetailPaneWidth, + min(520, availablePaneWidth - minimumReferencePaneWidth - minimumCommitPaneWidth) + ) + let resolvedDetailPaneWidth = constrained( + detailPaneWidth, + minimum: minimumDetailPaneWidth, + maximum: maximumDetailPaneWidth + ) + let maximumReferencePaneWidth = max( + minimumReferencePaneWidth, + min(480, availablePaneWidth - resolvedDetailPaneWidth - minimumCommitPaneWidth) + ) + let resolvedReferencePaneWidth = constrained( + referencePaneWidth, + minimum: minimumReferencePaneWidth, + maximum: maximumReferencePaneWidth + ) + + HStack(spacing: 0) { + referencePane + .frame(width: resolvedReferencePaneWidth) + + SplitHandleView( + axis: .horizontal, + onDragStarted: { + referencePaneDragStart = resolvedReferencePaneWidth + }, + onDragChanged: { translation in + referencePaneWidth = constrained( + referencePaneDragStart + translation, + minimum: minimumReferencePaneWidth, + maximum: maximumReferencePaneWidth + ) + }, + onDragEnded: { translation in + referencePaneWidth = constrained( + referencePaneDragStart + translation, + minimum: minimumReferencePaneWidth, + maximum: maximumReferencePaneWidth + ) + } + ) + + commitPane + .frame(minWidth: minimumCommitPaneWidth, maxWidth: .infinity) + + SplitHandleView( + axis: .horizontal, + onDragStarted: { + detailPaneDragStart = resolvedDetailPaneWidth + }, + onDragChanged: { translation in + detailPaneWidth = constrained( + detailPaneDragStart - translation, + minimum: minimumDetailPaneWidth, + maximum: maximumDetailPaneWidth + ) + }, + onDragEnded: { translation in + detailPaneWidth = constrained( + detailPaneDragStart - translation, + minimum: minimumDetailPaneWidth, + maximum: maximumDetailPaneWidth + ) + } + ) + + detailPane + .frame(width: resolvedDetailPaneWidth) + } + } + } + + /// The New Tag sheet and its delete confirmation live in a modifier + /// because the main `body` expression is already close to the type-checker + /// limit; an explicit `ViewModifier` keeps both type-checkable. + private struct GitTagDialogsModifier: ViewModifier { + @Binding var tagDialogRequest: GitTagDialogRequest? + @Binding var pendingTagDeletion: GitReference? + @EnvironmentObject private var model: AppModel + + func body(content: Content) -> some View { + content + .sheet(item: $tagDialogRequest) { request in + GitTagNameDialog(request: request) { name, message in + // Returning the failure keeps the dialog open so the + // error appears where the user typed, like IntelliJ's + // New Tag dialog. + await model.createTag(at: request.commit, name: name, message: message) + } + } + .confirmationDialog( + "Delete tag '\(pendingTagDeletion?.shortName ?? "")'?", + isPresented: Binding( + get: { pendingTagDeletion != nil }, + set: { if !$0 { pendingTagDeletion = nil } } + ), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + guard let reference = pendingTagDeletion else { return } + pendingTagDeletion = nil + Task { await model.deleteTag(reference) } + } + .disabled(model.isPerformingBranchOperation) + .lithePointer() + Button("Cancel", role: .cancel) { + pendingTagDeletion = nil + } + .lithePointer() + } message: { + Text("This removes the tag from the repository and affects collaborators who reference it. You can restore it from the banner afterwards.") + } + } } private var toolWindowHeader: some View { @@ -643,6 +724,48 @@ struct GitLogView: View { } } + /// IntelliJ-style "deleted ref [Restore]" notice. The restore record lives + /// in session state, so closing the banner ends the restore opportunity. + private func deletedReferenceBanner( + icon: String, + message: String, + onRestore: @escaping () async -> Void, + onDismiss: @escaping () -> Void + ) -> some View { + HStack(spacing: 7) { + LitheSystemIcon(systemImage: icon, size: 13) + .foregroundStyle(LitheTheme.warning) + Text(message) + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + Spacer(minLength: 8) + Button("Restore") { + Task { await onRestore() } + } + .controlSize(.small) + .buttonStyle(.borderedProminent) + .tint(LitheTheme.accent) + .disabled(model.isPerformingBranchOperation) + .lithePointer() + Button { + onDismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(LitheTheme.secondaryText) + } + .litheIconButton() + .help("Dismiss") + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(LitheTheme.raised) + .overlay(alignment: .bottom) { + Rectangle().fill(LitheTheme.divider).frame(height: 1) + } + } + private var referencePane: some View { VStack(spacing: 0) { HStack(spacing: 4) { @@ -932,6 +1055,20 @@ struct GitLogView: View { } .disabled(model.isPerformingBranchOperation) } + + if reference.kind == .tag { + Divider() + + if reference.supportsTagDeletion { + Button("Delete Tag…", role: .destructive) { + pendingTagDeletion = reference + } + .disabled(model.isPerformingBranchOperation) + } else { + Button("Delete Tag… (target is not a commit)") {} + .disabled(true) + } + } } } @@ -1282,6 +1419,9 @@ struct GitLogView: View { }, onReset: { commit in pendingOperation.wrappedValue = GitCommitOperationRequest(kind: .reset, commit: commit) + }, + onCreateTag: { commit in + tagDialogRequest = GitTagDialogRequest(commit: commit) } ) } @@ -2055,6 +2195,105 @@ private struct GitBranchNameDialog: View { } } +private struct GitTagDialogRequest: Identifiable { + let id = UUID() + let commit: GitCommit +} + +/// New Tag dialog mirroring IntelliJ's: a required name plus an optional +/// message (annotated tag when non-empty). Local validation shows inline and +/// keeps the dialog open; a server-side failure returned by `onSubmit` (for +/// example a duplicate name) is shown here as well instead of a notification. +private struct GitTagNameDialog: View { + @Environment(\.dismiss) private var dismiss + let request: GitTagDialogRequest + let onSubmit: (String, String) async -> String? + + @State private var name = "" + @State private var message = "" + @State private var submitError: String? + @State private var isSubmitting = false + @FocusState private var nameFieldFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 5) { + Text("New Tag") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + Text("Create on commit \(request.commit.shortHash). Leave the message empty for a lightweight tag.") + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + } + + TextField("Tag name", text: $name) + .textFieldStyle(.roundedBorder) + .focused($nameFieldFocused) + .onSubmit(submit) + + VStack(alignment: .leading, spacing: 3) { + TextField("Message (optional)", text: $message, axis: .vertical) + .textFieldStyle(.roundedBorder) + .lineLimit(1...4) + Text("A message creates an annotated tag.") + .font(.system(size: 10.5)) + .foregroundStyle(LitheTheme.secondaryText) + } + + if let error = validationError ?? submitError { + Text(error) + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.error) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + .lithePointer() + Button("Create", action: submit) + .buttonStyle(.borderedProminent) + .lithePointer() + .tint(LitheTheme.accent) + .keyboardShortcut(.defaultAction) + .disabled(trimmedName.isEmpty || validationError != nil || isSubmitting) + } + } + .padding(20) + .frame(width: 420) + .background(LitheTheme.raised) + .onAppear { nameFieldFocused = true } + } + + private var trimmedName: String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Mirrors the refname rules the Rust core enforces so illegal names are + /// rejected before a request is sent. + private var validationError: String? { + let name = trimmedName + guard !name.isEmpty else { return nil } + return GitTagNameValidator.validationError(for: name) + } + + private func submit() { + guard !trimmedName.isEmpty, validationError == nil, !isSubmitting else { return } + isSubmitting = true + submitError = nil + Task { + let error = await onSubmit(trimmedName, message) + isSubmitting = false + if let error { + submitError = error + } else { + dismiss() + } + } + } +} + /// Offered when local changes would be overwritten by a checkout, so the user can pick a /// resolution instead of being handed Git's raw refusal. /// Offers to stash when uncommitted changes block a merge or rebase. diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 3067d887..b7e335ab 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -33,6 +33,14 @@ package final class GitFeatureModel: ObservableObject { @Published package var pendingConflictRollback: GitConflictRollbackRequest? @Published package private(set) var pendingStashRestoreConflict: GitStashRestoreConflictRequest? @Published package private(set) var isStashRestoreConflictNoticeVisible = false + /// The most recently deleted tag, kept in session state so the Git log can + /// offer a restore. A later tag deletion replaces it on success or clears + /// it on failure; branch recovery is independent, so both banners may be + /// visible. Closing the banner or `reset()` clears this session-only state. + @Published package private(set) var recentlyDeletedTag: GitTagDeletion? + /// The most recently deleted local branch. Later branch attempts follow the + /// same replace-or-clear rule without changing tag recovery state. + @Published package private(set) var recentlyDeletedBranch: GitBranchDeletion? @Published package private(set) var gitConflictFilterPaths: Set = [] @Published package private(set) var requestedStashReference: String? /// Set whenever Git is mid-merge, mid-rebase, mid-cherry-pick, or mid-revert. @@ -170,6 +178,8 @@ package final class GitFeatureModel: ObservableObject { pendingConflictRollback = nil pendingStashRestoreConflict = nil isStashRestoreConflictNoticeVisible = false + recentlyDeletedTag = nil + recentlyDeletedBranch = nil gitConflictFilterPaths = [] requestedStashReference = nil deferredSavedChanges = nil @@ -1634,10 +1644,135 @@ package final class GitFeatureModel: ObservableObject { isPerformingBranchOperation = true let result = await withGitOperation { await service.deleteBranch(reference, at: gitRepositoryRoot) } isPerformingBranchOperation = false - notify?(result.succeeded ? "Deleted \(reference.shortName)" : trimmedMessage(result)) + if let deletion = result.branchDeletion { + recentlyDeletedBranch = deletion + notify?( + result.succeeded + ? successfulMessage(result, fallback: "Deleted branch \(deletion.name)") + : trimmedMessage(result) + ) + } else { + recentlyDeletedBranch = nil + notify?(result.succeeded ? "Deleted \(reference.shortName)" : trimmedMessage(result)) + } await refreshGit() } + /// Rebuilds the deleted branch at its recorded commit. A failure (for + /// example the name was re-created elsewhere) keeps the record so the user + /// can retry or close the banner themselves. + package func restoreRecentlyDeletedBranch() async { + guard let deletion = recentlyDeletedBranch, let gitRepositoryRoot else { return } + isPerformingBranchOperation = true + let result = await withGitOperation { + await service.createBranch( + named: deletion.name, + from: GitReference( + fullName: deletion.deletedTarget, + shortName: deletion.deletedTarget, + kind: .local, + isCurrent: false, + upstreamShortName: nil + ), + checkout: false, + at: gitRepositoryRoot + ) + } + isPerformingBranchOperation = false + if result.succeeded { + recentlyDeletedBranch = nil + notify?("Restored branch \(deletion.name)") + await refreshGit() + } else { + notify?(trimmedMessage(result)) + } + } + + package func dismissDeletedBranchBanner() { + recentlyDeletedBranch = nil + } + + /// Creates a lightweight or annotated tag. Returns `nil` on success so a + /// dialog can stay open and show the failure where the user typed; the + /// caller decides whether to surface the returned message itself. + @discardableResult + package func createTag( + at commit: GitCommit, + name rawName: String, + message: String + ) async -> String? { + guard let gitRepositoryRoot else { return "No Git repository is open" } + let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return "Enter a tag name" } + let annotation = message.trimmingCharacters(in: .whitespacesAndNewlines) + isPerformingBranchOperation = true + let result = await withGitOperation { + await service.createTag( + named: name, + at: commit.hash, + message: annotation.isEmpty ? nil : annotation, + at: gitRepositoryRoot + ) + } + isPerformingBranchOperation = false + if result.succeeded { + notify?("Created tag \(name)") + await refreshGit() + return nil + } + return trimmedMessage(result) + } + + package func deleteTag(_ reference: GitReference) async { + guard let gitRepositoryRoot else { return } + isPerformingBranchOperation = true + let result = await withGitOperation { + await service.deleteTag(named: reference.shortName, at: gitRepositoryRoot) + } + isPerformingBranchOperation = false + if result.succeeded, let deletion = result.tagDeletion { + recentlyDeletedTag = deletion + notify?("Deleted tag \(deletion.name)") + } else { + recentlyDeletedTag = nil + notify?(trimmedMessage(result)) + } + await refreshGit() + } + + /// Rebuilds the deleted tag at its recorded commit. A failure (for example + /// the name was re-created elsewhere) keeps the record so the user can + /// retry or close the banner themselves. + package func restoreRecentlyDeletedTag() async { + guard let deletion = recentlyDeletedTag, let gitRepositoryRoot else { return } + guard deletion.hasConsistentKindAndMessage else { + recentlyDeletedTag = nil + notify?("The deleted tag recovery record is invalid") + return + } + isPerformingBranchOperation = true + let result = await withGitOperation { + await service.createTag( + named: deletion.name, + at: deletion.deletedTarget, + message: deletion.message, + at: gitRepositoryRoot + ) + } + isPerformingBranchOperation = false + if result.succeeded { + recentlyDeletedTag = nil + notify?("Restored tag \(deletion.name)") + await refreshGit() + } else { + notify?(trimmedMessage(result)) + } + } + + package func dismissDeletedTagBanner() { + recentlyDeletedTag = nil + } + /// Records the merge or rebase commit Git is waiting on once its conflicts are /// resolved. Rust refuses while any file is still conflicted, so the failure /// message names what is left. diff --git a/macos/Sources/LitheGitModule/Models/GitModels.swift b/macos/Sources/LitheGitModule/Models/GitModels.swift index 236b2a4a..e035b534 100644 --- a/macos/Sources/LitheGitModule/Models/GitModels.swift +++ b/macos/Sources/LitheGitModule/Models/GitModels.swift @@ -3,6 +3,42 @@ import LitheCoreContracts package typealias GitWatchContext = LitheCoreContracts.GitWatchContext +/// Mirrors the shared Rust refname checks used by tag mutations so the macOS +/// dialog can reject the same invalid names before crossing the Core boundary. +public enum GitTagNameValidator { + public static func isValid(_ value: String) -> Bool { + !isInvalid(value) + } + + public static func validationError(for value: String) -> String? { + isInvalid(value) ? "Invalid Git tag name." : nil + } + + private static func isInvalid(_ value: String) -> Bool { + if value.isEmpty + || value.hasPrefix("-") + || value == "@" + || value.hasPrefix("/") + || value.hasSuffix("/") + || value.hasSuffix(".") + || value.contains("..") + || value.contains("@{") + || value.contains("//") + { + return true + } + if value.unicodeScalars.contains(where: { scalar in + CharacterSet.controlCharacters.contains(scalar) + || " ~^:?*[\\".unicodeScalars.contains(scalar) + }) { + return true + } + return value.split(separator: "/", omittingEmptySubsequences: false).contains { component in + component.hasPrefix(".") || component.hasSuffix(".lock") + } + } +} + package struct GitSnapshot: Sendable { package let repositoryRoot: URL package let branch: String @@ -20,11 +56,27 @@ package struct GitReference: Identifiable, Hashable, Sendable { package let fullName: String package let shortName: String package let kind: GitReferenceKind + package let peelsToCommit: Bool package let isCurrent: Bool package let upstreamShortName: String? - package init(fullName: String, shortName: String, kind: GitReferenceKind, isCurrent: Bool, upstreamShortName: String?) { self.fullName = fullName; self.shortName = shortName; self.kind = kind; self.isCurrent = isCurrent; self.upstreamShortName = upstreamShortName } + package init( + fullName: String, + shortName: String, + kind: GitReferenceKind, + peelsToCommit: Bool = true, + isCurrent: Bool, + upstreamShortName: String? + ) { + self.fullName = fullName + self.shortName = shortName + self.kind = kind + self.peelsToCommit = peelsToCommit + self.isCurrent = isCurrent + self.upstreamShortName = upstreamShortName + } package var id: String { fullName } + package var supportsTagDeletion: Bool { kind == .tag && peelsToCommit } } package struct GitStash: Identifiable, Hashable, Sendable { diff --git a/macos/Sources/LitheGitModule/Ports/GitPorts.swift b/macos/Sources/LitheGitModule/Ports/GitPorts.swift index df478682..fbfd620b 100644 --- a/macos/Sources/LitheGitModule/Ports/GitPorts.swift +++ b/macos/Sources/LitheGitModule/Ports/GitPorts.swift @@ -21,6 +21,56 @@ public struct GitProcessInvocation: Equatable, Sendable { public var output: String { standardOutput + standardError } } +/// The two tag object forms supported by the shared Git contract. +public enum GitTagKind: String, Codable, Sendable { + case lightweight + case annotated +} + +/// Everything a host needs to rebuild a deleted tag later: the record is kept +/// in session state only, and restores replay `createTag` with these values. +public struct GitTagDeletion: Equatable, Sendable { + public let name: String + /// The commit the deleted ref resolved to (peeled for annotated tags). + public let deletedTarget: String + /// Tag form taken from the deleted ref's object type. + public let kind: GitTagKind + /// Original annotation, if any; lightweight tags carry `nil`. + public let message: String? + + public init(name: String, deletedTarget: String, kind: GitTagKind, message: String?) { + self.name = name + self.deletedTarget = deletedTarget + self.kind = kind + self.message = message + } + + public var isAnnotated: Bool { kind == .annotated } + + /// A lightweight tag has no annotation, while an annotated tag always + /// carries a message value (which may be empty) so restore preserves form. + public var hasConsistentKindAndMessage: Bool { + switch kind { + case .lightweight: + message == nil + case .annotated: + message != nil + } + } +} + +/// A deleted local branch and the commit it pointed at, kept in session state +/// so the host can offer a restore. +public struct GitBranchDeletion: Equatable, Sendable { + public let name: String + public let deletedTarget: String + + public init(name: String, deletedTarget: String) { + self.name = name + self.deletedTarget = deletedTarget + } +} + public struct GitOperationWarning: Equatable, Sendable { public let code: String public let message: String @@ -42,6 +92,8 @@ public struct GitProcessResult: Sendable { public let invocations: [GitProcessInvocation] public let operationErrorMessage: String? public let stashRestoreConflict: GitStashRestoreConflict? + public let tagDeletion: GitTagDeletion? + public let branchDeletion: GitBranchDeletion? public let warnings: [GitOperationWarning] public init( arguments: [String] = [], @@ -52,6 +104,8 @@ public struct GitProcessResult: Sendable { invocations: [GitProcessInvocation] = [], operationErrorMessage: String? = nil, stashRestoreConflict: GitStashRestoreConflict? = nil, + tagDeletion: GitTagDeletion? = nil, + branchDeletion: GitBranchDeletion? = nil, warnings: [GitOperationWarning] = [] ) { self.arguments = arguments @@ -62,6 +116,8 @@ public struct GitProcessResult: Sendable { self.invocations = invocations self.operationErrorMessage = operationErrorMessage self.stashRestoreConflict = stashRestoreConflict + self.tagDeletion = tagDeletion + self.branchDeletion = branchDeletion self.warnings = warnings } } diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index 9c93abf2..1d3c89ea 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -119,6 +119,8 @@ package protocol GitOperations: Sendable { func popStash(_ stash: GitStash, at rootURL: URL) -> GitProcessResult? func dropStash(_ stash: GitStash, at rootURL: URL) -> GitProcessResult? func stageAll(at rootURL: URL) -> GitProcessResult? + func createTag(named name: String, at revision: String, message: String?, rootURL: URL) -> GitProcessResult? + func deleteTag(named name: String, rootURL: URL) -> GitProcessResult? } package typealias GitWatchContextProviding = LitheCoreContracts.GitWatchContextProviding @@ -142,6 +144,8 @@ package struct GitService: Sendable { package let invocations: [GitProcessInvocation] package let operationErrorMessage: String? package let stashRestoreConflict: GitStashRestoreConflict? + package let tagDeletion: GitTagDeletion? + package let branchDeletion: GitBranchDeletion? package let warnings: [GitOperationWarning] package init( @@ -154,6 +158,8 @@ package struct GitService: Sendable { invocations: [GitProcessInvocation] = [], operationErrorMessage: String? = nil, stashRestoreConflict: GitStashRestoreConflict? = nil, + tagDeletion: GitTagDeletion? = nil, + branchDeletion: GitBranchDeletion? = nil, warnings: [GitOperationWarning] = [] ) { self.workingDirectory = workingDirectory @@ -165,6 +171,8 @@ package struct GitService: Sendable { self.invocations = invocations self.operationErrorMessage = operationErrorMessage self.stashRestoreConflict = stashRestoreConflict + self.tagDeletion = tagDeletion + self.branchDeletion = branchDeletion self.warnings = warnings } @@ -635,6 +643,24 @@ package struct GitService: Sendable { await command(at: repositoryRoot) { $0.stageAll(at: repositoryRoot) } } + /// Creates a lightweight or annotated tag: a non-empty `message` produces + /// the annotated form. `revision` is the commit hash or resolvable + /// revision the tag should point at. + func createTag( + named name: String, + at revision: String, + message: String?, + at repositoryRoot: URL + ) async -> CommandResult { + await command(at: repositoryRoot) { + $0.createTag(named: name, at: revision, message: message, rootURL: repositoryRoot) + } + } + + func deleteTag(named name: String, at repositoryRoot: URL) async -> CommandResult { + await command(at: repositoryRoot) { $0.deleteTag(named: name, rootURL: repositoryRoot) } + } + private func command( at workingDirectory: URL? = nil, fallbackArguments: [String] = [], @@ -655,6 +681,8 @@ package struct GitService: Sendable { invocations: result?.invocations ?? [], operationErrorMessage: result?.operationErrorMessage, stashRestoreConflict: result?.stashRestoreConflict, + tagDeletion: result?.tagDeletion, + branchDeletion: result?.branchDeletion, warnings: result?.warnings ?? [] ) }.value diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index 6ca0bbfc..219251ef 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -441,9 +441,609 @@ struct GitModuleTests { #expect(feature.gitConsoleEntries.first?.succeeded == true) } + // MARK: Tag management + @Test - func gitServicePreservesExecutedArgumentsAndWorkingDirectory() async { + func gitTagDeletionCapabilityRequiresACommitTarget() { + let commitTag = GitReference( + fullName: "refs/tags/v1.0", + shortName: "v1.0", + kind: .tag, + peelsToCommit: true, + isCurrent: false, + upstreamShortName: nil + ) + let treeTag = GitReference( + fullName: "refs/tags/tree-tag", + shortName: "tree-tag", + kind: .tag, + peelsToCommit: false, + isCurrent: false, + upstreamShortName: nil + ) + + #expect(commitTag.supportsTagDeletion) + #expect(!treeTag.supportsTagDeletion) + } + + private func makeTagTestFeature( + _ operations: TestGitOperations, + onNotify: @escaping @MainActor (String) -> Void = { _ in } + ) -> (GitFeatureModel, URL) { let root = URL(fileURLWithPath: "/workspace") + let service = GitService(operations: operations) + let feature = GitFeatureModel(service: service) + feature.configure( + workspaceURLProvider: { root }, + isGitLogVisibleProvider: { false }, + notify: onNotify, + onStateRefreshed: {} + ) + return (feature, root) + } + + private func makeTagCommit() -> GitCommit { + GitCommit( + hash: "abc123def456", + shortHash: "abc123d", + parentHashes: [], + authorName: "Ada Lovelace", + authorEmail: "ada@example.com", + date: "2026/08/30 10:00", + subject: "Initial", + decorations: "" + ) + } + + @Test + func gitTagNameValidationMatchesTheSharedContractFixture() throws { + struct TagNames: Decodable { + let valid: [String] + let invalid: [String] + } + + let fixtureURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // LitheGitModuleTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // macos + .deletingLastPathComponent() // repository root + .appendingPathComponent("shared/fixtures/git/tag-names.json") + let fixture = try JSONDecoder().decode(TagNames.self, from: Data(contentsOf: fixtureURL)) + + for name in fixture.valid { + #expect(GitTagNameValidator.isValid(name), "expected valid tag name: \(name)") + } + for name in fixture.invalid { + #expect(!GitTagNameValidator.isValid(name), "expected invalid tag name: \(name)") + } + } + + @Test + func gitTagDeletionRequiresKindAndMessageToDescribeTheSameTagForm() { + #expect(GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .lightweight, + message: nil + ).hasConsistentKindAndMessage) + #expect(GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .annotated, + message: "" + ).hasConsistentKindAndMessage) + #expect(!GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .lightweight, + message: "release" + ).hasConsistentKindAndMessage) + #expect(!GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .annotated, + message: nil + ).hasConsistentKindAndMessage) + } + + @Test + func gitTagCreationSucceedsSilentlyForTheDialogAndNotifiesOnSuccess() async { + var notifications: [String] = [] + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + createTagResult: GitProcessResult(arguments: ["tag", "v1.0", "abc123def456"], output: "", exitCode: 0) + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + + // An empty result would mean the dialog shows a generic failure, so a + // successful create must return nil and notify instead. + let error = await feature.createTag(at: makeTagCommit(), name: "v1.0", message: "") + + #expect(error == nil) + #expect(notifications == ["Created tag v1.0"]) + } + + @Test + func gitTagCreationReturnsTheFailureToTheDialogWithoutNotifying() async { + var notifications: [String] = [] + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []) + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + + let error = await feature.createTag(at: makeTagCommit(), name: "v1.0", message: "") + + #expect(error == "Rust Core Git operation failed") + #expect(notifications.isEmpty) + } + + @Test + func gitTagDeletionKeepsARestorableSessionRecord() async { + var notifications: [String] = [] + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + deleteTagResult: GitProcessResult( + arguments: ["tag", "-d", "v1.0"], + output: "Deleted tag 'v1.0'\n", + exitCode: 0, + tagDeletion: GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .annotated, + message: "release" + ) + ) + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/tags/v1.0", + shortName: "v1.0", + kind: .tag, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteTag(reference) + + #expect(feature.recentlyDeletedTag == GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .annotated, + message: "release" + )) + #expect(notifications == ["Deleted tag v1.0"]) + + feature.dismissDeletedTagBanner() + #expect(feature.recentlyDeletedTag == nil) + } + + @Test + func gitTagDeletionFailureRecordsNothingAndNotifiesTheError() async { + var notifications: [String] = [] + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + deleteTagResult: GitProcessResult( + arguments: ["tag", "-d", "v1.0"], + output: "The tag 'v1.0' does not exist", + exitCode: 1 + ) + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/tags/v1.0", + shortName: "v1.0", + kind: .tag, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteTag(reference) + + #expect(feature.recentlyDeletedTag == nil) + #expect(notifications == ["The tag 'v1.0' does not exist"]) + } + + @Test + func gitTagRestoreReplaysRecordedNameTargetAndMessage() async { + var notifications: [String] = [] + let recorder = TagCallRecorder() + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + createTagResult: GitProcessResult(arguments: ["tag", "-a", "v1.0", "-m", "release", "abc123def456"], output: "", exitCode: 0), + deleteTagResult: GitProcessResult( + arguments: ["tag", "-d", "v1.0"], + output: "Deleted tag 'v1.0'\n", + exitCode: 0, + tagDeletion: GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .annotated, + message: "release" + ) + ), + tagCallRecorder: recorder + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/tags/v1.0", + shortName: "v1.0", + kind: .tag, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteTag(reference) + await feature.restoreRecentlyDeletedTag() + + // Exactly one delete and one restore create must have run, and the + // restore must replay exactly the recorded deletion record so the + // rebuilt annotated tag points at the original commit with its + // message. The delete itself records no revision. + #expect(recorder.recorded.count == 2) + #expect(recorder.recorded.first?.name == "v1.0") + #expect(recorder.recorded.last == TagCallRecorder.Call( + name: "v1.0", + revision: "abc123def456", + message: "release" + )) + #expect(feature.recentlyDeletedTag == nil) + #expect(notifications == ["Deleted tag v1.0", "Restored tag v1.0"]) + } + + @Test + func gitTagRestoreFailureKeepsTheRecordForARetry() async { + var notifications: [String] = [] + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + createTagResult: GitProcessResult( + arguments: ["tag", "v1.0", "abc123def456"], + output: "A tag named 'v1.0' already exists", + exitCode: 1 + ), + deleteTagResult: GitProcessResult( + arguments: ["tag", "-d", "v1.0"], + output: "Deleted tag 'v1.0'\n", + exitCode: 0, + tagDeletion: GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .lightweight, + message: nil + ) + ) + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/tags/v1.0", + shortName: "v1.0", + kind: .tag, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteTag(reference) + await feature.restoreRecentlyDeletedTag() + + // The user can retry after fixing the conflict, or close the banner. + #expect(feature.recentlyDeletedTag?.name == "v1.0") + #expect(notifications == ["Deleted tag v1.0", "A tag named 'v1.0' already exists"]) + } + + @Test + func gitTagRestoreRejectsAnInconsistentRecoveryRecord() async { + var notifications: [String] = [] + let recorder = TagCallRecorder() + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + createTagResult: GitProcessResult(arguments: ["tag", "v1.0"], output: "", exitCode: 0), + deleteTagResult: GitProcessResult( + arguments: ["tag", "-d", "v1.0"], + output: "Deleted tag 'v1.0'\n", + exitCode: 0, + tagDeletion: GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .lightweight, + message: "unexpected annotation" + ) + ), + tagCallRecorder: recorder + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/tags/v1.0", + shortName: "v1.0", + kind: .tag, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteTag(reference) + await feature.restoreRecentlyDeletedTag() + + #expect(feature.recentlyDeletedTag == nil) + #expect(recorder.recorded.count == 1, "invalid recovery data must not issue createTag") + #expect(notifications == ["Deleted tag v1.0", "The deleted tag recovery record is invalid"]) + } + + @Test + func gitFeatureModelResetClearsTheRestorableTagRecord() async { + var notifications: [String] = [] + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + deleteTagResult: GitProcessResult( + arguments: ["tag", "-d", "v1.0"], + output: "Deleted tag 'v1.0'\n", + exitCode: 0, + tagDeletion: GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .lightweight, + message: nil + ) + ) + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/tags/v1.0", + shortName: "v1.0", + kind: .tag, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteTag(reference) + #expect(feature.recentlyDeletedTag != nil) + + // Project close resets the model; the deletion record must not survive + // into the next session. + feature.reset() + #expect(feature.recentlyDeletedTag == nil) + } + + // MARK: Branch deletion restore + + @Test + func gitBranchDeletionKeepsARestorableSessionRecord() async { + var notifications: [String] = [] + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + deleteBranchResult: GitProcessResult( + arguments: ["branch", "-d", "--", "feature/short-lived"], + output: "Deleted branch feature/short-lived\n", + exitCode: 0, + branchDeletion: GitBranchDeletion( + name: "feature/short-lived", + deletedTarget: "abc123def456" + ) + ) + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/heads/feature/short-lived", + shortName: "feature/short-lived", + kind: .local, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteBranch(reference) + + #expect(feature.recentlyDeletedBranch == GitBranchDeletion( + name: "feature/short-lived", + deletedTarget: "abc123def456" + )) + #expect(notifications == ["Deleted branch feature/short-lived"]) + + feature.dismissDeletedBranchBanner() + #expect(feature.recentlyDeletedBranch == nil) + } + + @Test + func gitBranchConfigCleanupFailureKeepsTheRestorableDeletionRecord() async { + var notifications: [String] = [] + let warning = "Could not remove configuration for deleted branch 'feature/short-lived'" + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + deleteBranchResult: GitProcessResult( + arguments: ["update-ref", "-d", "refs/heads/feature/short-lived"], + output: "", + exitCode: 0, + branchDeletion: GitBranchDeletion( + name: "feature/short-lived", + deletedTarget: "abc123def456" + ), + warnings: [GitOperationWarning( + code: "branch_config_cleanup_failed", + message: warning + )] + ) + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/heads/feature/short-lived", + shortName: "feature/short-lived", + kind: .local, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteBranch(reference) + + #expect(feature.recentlyDeletedBranch == GitBranchDeletion( + name: "feature/short-lived", + deletedTarget: "abc123def456" + )) + #expect(notifications == ["Deleted branch feature/short-lived: \(warning)"]) + } + + @Test + func gitBranchDeletionFailureClearsThePreviousRecoveryRecord() async { + var notifications: [String] = [] + let results = GitProcessResultQueue([ + GitProcessResult( + arguments: ["branch", "-d", "--", "feature/a"], + output: "Deleted branch feature/a\n", + exitCode: 0, + branchDeletion: GitBranchDeletion(name: "feature/a", deletedTarget: "abc123def456") + ), + GitProcessResult( + arguments: ["branch", "-d", "--", "feature/b"], + output: "The branch 'feature/b' does not exist", + exitCode: 1 + ) + ]) + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + deleteBranchResults: results + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + + await feature.deleteBranch(GitReference( + fullName: "refs/heads/feature/a", + shortName: "feature/a", + kind: .local, + isCurrent: false, + upstreamShortName: nil + )) + #expect(feature.recentlyDeletedBranch?.name == "feature/a") + + await feature.deleteBranch(GitReference( + fullName: "refs/heads/feature/b", + shortName: "feature/b", + kind: .local, + isCurrent: false, + upstreamShortName: nil + )) + + #expect(feature.recentlyDeletedBranch == nil) + #expect(notifications == ["Deleted branch feature/a", "The branch 'feature/b' does not exist"]) + } + + @Test + func gitTagAndBranchRecoveryRecordsCanCoexist() async { + let (feature, _) = makeTagTestFeature(TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + deleteTagResult: GitProcessResult( + arguments: ["tag", "-d", "v1.0"], + output: "Deleted tag 'v1.0'\n", + exitCode: 0, + tagDeletion: GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .lightweight, + message: nil + ) + ), + deleteBranchResult: GitProcessResult( + arguments: ["branch", "-d", "--", "feature/a"], + output: "Deleted branch feature/a\n", + exitCode: 0, + branchDeletion: GitBranchDeletion(name: "feature/a", deletedTarget: "abc123def456") + ) + )) + await feature.refreshGit() + + await feature.deleteTag(GitReference( + fullName: "refs/tags/v1.0", + shortName: "v1.0", + kind: .tag, + isCurrent: false, + upstreamShortName: nil + )) + await feature.deleteBranch(GitReference( + fullName: "refs/heads/feature/a", + shortName: "feature/a", + kind: .local, + isCurrent: false, + upstreamShortName: nil + )) + + #expect(feature.recentlyDeletedTag?.name == "v1.0") + #expect(feature.recentlyDeletedBranch?.name == "feature/a") + } + + @Test + func gitBranchRestoreReplaysRecordedNameAndTarget() async { + var notifications: [String] = [] + let recorder = BranchCallRecorder() + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + createBranchResult: GitProcessResult(arguments: ["branch", "feature/short-lived", "abc123def456"], output: "", exitCode: 0), + deleteBranchResult: GitProcessResult( + arguments: ["branch", "-d", "--", "feature/short-lived"], + output: "Deleted branch feature/short-lived\n", + exitCode: 0, + branchDeletion: GitBranchDeletion( + name: "feature/short-lived", + deletedTarget: "abc123def456" + ) + ), + branchCallRecorder: recorder + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/heads/feature/short-lived", + shortName: "feature/short-lived", + kind: .local, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteBranch(reference) + await feature.restoreRecentlyDeletedBranch() + + // The restore replays createBranch against the recorded commit without + // checking the branch out. + #expect(Array(recorder.recorded.suffix(1)) == [ + BranchCallRecorder.Call(name: "feature/short-lived", reference: "abc123def456", checkout: false) + ]) + #expect(feature.recentlyDeletedBranch == nil) + #expect(notifications == ["Deleted branch feature/short-lived", "Restored branch feature/short-lived"]) + + // Project close drops the restorable record as well. + feature.reset() + #expect(feature.recentlyDeletedBranch == nil) + } + + @Test + func gitServicePreservesExecutedArgumentsAndWorkingDirectory() async { let root = URL(fileURLWithPath: "/workspace") let change = GitChange( repositoryRoot: root, path: "README.md", @@ -1456,6 +2056,72 @@ private func waitForGitWorkToBecomeIdle( return !isActive() } +/// Records tag create/delete arguments so restore flows can be asserted on +/// the exact parameters the feature model replays. +private final class TagCallRecorder: @unchecked Sendable { + struct Call: Equatable { + let name: String + let revision: String + let message: String? + } + + private let lock = NSLock() + private var calls: [Call] = [] + + func record(_ call: Call) { + lock.lock() + calls.append(call) + lock.unlock() + } + + var recorded: [Call] { + lock.lock() + defer { lock.unlock() } + return calls + } +} + +/// Records branch create/delete arguments for the branch restore flow. +private final class BranchCallRecorder: @unchecked Sendable { + struct Call: Equatable { + let name: String + let reference: String + let checkout: Bool + } + + private let lock = NSLock() + private var calls: [Call] = [] + + func record(_ call: Call) { + lock.lock() + calls.append(call) + lock.unlock() + } + + var recorded: [Call] { + lock.lock() + defer { lock.unlock() } + return calls + } +} + +/// Supplies deterministic per-call results for consecutive branch mutations. +private final class GitProcessResultQueue: @unchecked Sendable { + private let lock = NSLock() + private var results: [GitProcessResult] + + init(_ results: [GitProcessResult]) { + self.results = results + } + + func next() -> GitProcessResult? { + lock.lock() + defer { lock.unlock() } + guard !results.isEmpty else { return nil } + return results.removeFirst() + } +} + private struct TestGitOperations: GitOperations { private let snapshotValue: GitSnapshot? private let comparisonValue: GitBranchComparison? @@ -1469,6 +2135,13 @@ private struct TestGitOperations: GitOperations { private let runGate: TestGitRunGate? private let filesRecorder: GitFilesCallRecorder? private let filesGate: GitFilesLoadGate? + private let createTagResult: GitProcessResult? + private let deleteTagResult: GitProcessResult? + private let tagCallRecorder: TagCallRecorder? + private let createBranchResult: GitProcessResult? + private let deleteBranchResult: GitProcessResult? + private let deleteBranchResults: GitProcessResultQueue? + private let branchCallRecorder: BranchCallRecorder? init( snapshotValue: GitSnapshot? = nil, @@ -1482,7 +2155,14 @@ private struct TestGitOperations: GitOperations { stageResult: GitProcessResult? = nil, runGate: TestGitRunGate? = nil, filesRecorder: GitFilesCallRecorder? = nil, - filesGate: GitFilesLoadGate? = nil + filesGate: GitFilesLoadGate? = nil, + createTagResult: GitProcessResult? = nil, + deleteTagResult: GitProcessResult? = nil, + tagCallRecorder: TagCallRecorder? = nil, + createBranchResult: GitProcessResult? = nil, + deleteBranchResult: GitProcessResult? = nil, + deleteBranchResults: GitProcessResultQueue? = nil, + branchCallRecorder: BranchCallRecorder? = nil ) { self.snapshotValue = snapshotValue self.comparisonValue = comparisonValue @@ -1496,6 +2176,13 @@ private struct TestGitOperations: GitOperations { self.runGate = runGate self.filesRecorder = filesRecorder self.filesGate = filesGate + self.createTagResult = createTagResult + self.deleteTagResult = deleteTagResult + self.tagCallRecorder = tagCallRecorder + self.createBranchResult = createBranchResult + self.deleteBranchResult = deleteBranchResult + self.deleteBranchResults = deleteBranchResults + self.branchCallRecorder = branchCallRecorder } func run(arguments: [String], workingDirectory: String, input: String?) -> GitProcessResult { @@ -1540,9 +2227,15 @@ private struct TestGitOperations: GitOperations { func cherryPick(_ hash: String, at rootURL: URL) -> GitProcessResult? { nil } func revert(_ hash: String, at rootURL: URL) -> GitProcessResult? { nil } func resetCurrentBranch(to hash: String, mode: String, at rootURL: URL) -> GitProcessResult? { nil } - func createBranch(named name: String, from reference: GitReference, checkout: Bool, at rootURL: URL) -> GitProcessResult? { nil } + func createBranch(named name: String, from reference: GitReference, checkout: Bool, at rootURL: URL) -> GitProcessResult? { + branchCallRecorder?.record(BranchCallRecorder.Call(name: name, reference: reference.fullName, checkout: checkout)) + return createBranchResult + } func renameBranch(_ reference: GitReference, to name: String, at rootURL: URL) -> GitProcessResult? { nil } - func deleteBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } + func deleteBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { + branchCallRecorder?.record(BranchCallRecorder.Call(name: reference.shortName, reference: reference.fullName, checkout: false)) + return deleteBranchResults?.next() ?? deleteBranchResult + } func mergeBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } func rebaseCurrentBranch(onto reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } func checkoutAndRebase(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { @@ -1582,4 +2275,12 @@ private struct TestGitOperations: GitOperations { func popStash(_ stash: GitStash, at rootURL: URL) -> GitProcessResult? { nil } func dropStash(_ stash: GitStash, at rootURL: URL) -> GitProcessResult? { nil } func stageAll(at rootURL: URL) -> GitProcessResult? { nil } + func createTag(named name: String, at revision: String, message: String?, rootURL: URL) -> GitProcessResult? { + tagCallRecorder?.record(TagCallRecorder.Call(name: name, revision: revision, message: message)) + return createTagResult + } + func deleteTag(named name: String, rootURL: URL) -> GitProcessResult? { + tagCallRecorder?.record(TagCallRecorder.Call(name: name, revision: "", message: nil)) + return deleteTagResult + } } diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 4f5328cd..2dcd5eeb 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -124,6 +124,14 @@ pub struct GitCommandResponse { /// output. #[serde(skip_serializing_if = "Option::is_none")] pub stash_restore: Option, + /// Present when a tag deletion succeeded, carrying everything a host needs + /// to offer a restore without re-querying the repository. + #[serde(skip_serializing_if = "Option::is_none")] + pub tag_deletion: Option, + /// Present when a local branch deletion succeeded, carrying the commit the + /// branch pointed at so the host can offer to recreate it. + #[serde(skip_serializing_if = "Option::is_none")] + pub branch_deletion: Option, /// Non-fatal follow-up failures after the requested repository mutation succeeded. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub warnings: Vec, @@ -179,6 +187,8 @@ impl GitProcessOutput { invocations: vec![invocation], operation_error: None, stash_restore: None, + tag_deletion: None, + branch_deletion: None, warnings: Vec::new(), } } @@ -254,6 +264,34 @@ pub struct GitStashRestoreResponse { pub conflicted_paths: Vec, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Deletion record that lets a host rebuild the deleted tag later. +/// +/// `deleted_target` is the commit the deleted ref resolved to (peeled for +/// annotated tags), so a restore can re-point a new tag at the same commit. +pub struct GitTagDeletionResponse { + /// Short name of the deleted tag, without the `refs/tags/` prefix. + pub name: String, + pub deleted_target: String, + /// `lightweight` or `annotated`, taken from the tag object type. + pub kind: String, + /// Annotation message; `None` only for lightweight tags. Empty annotated + /// messages remain `Some` so a restore does not change the tag form. + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Deletion record that lets a host recreate the deleted local branch later. +pub struct GitBranchDeletionResponse { + /// Short branch name, without the `refs/heads/` prefix. + pub name: String, + /// Commit the deleted branch pointed at when it was removed. + pub deleted_target: String, +} + #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] /// Complete identity of a Git reference supplied by a platform client. @@ -825,7 +863,7 @@ fn write_with_trace(request: GitWriteRequest) -> Result { let reference = write_request_reference(&root, &request)?; @@ -933,6 +971,44 @@ fn write_with_trace(request: GitWriteRequest) -> Result { + let name = validated_tag_name(request.name.as_deref())?; + let requested_target = validated_revision(request.revision.as_deref())?; + // Existence and resolvability probes run before Git so a duplicate + // or unresolvable target fails with a stable message instead of + // leaving the caller to parse localized `git tag` stderr. + if tag_exists(&root, &name)? { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("A tag named '{name}' already exists"), + )); + } + // Git can tag trees and blobs, but the deletion/restore contract + // promises a commit target, so anything else is rejected here and + // the tag is created against the resolved commit id. + let Some(target) = resolved_commit_target(&root, &requested_target)? else { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("Could not resolve tag target '{requested_target}'"), + )); + }; + // An explicit message field (even an empty one) selects the + // annotated form. Verbatim cleanup keeps restored CRLF and trailing + // blank lines intact; UIs trim newly entered messages before send. + arguments = match request.message.as_deref() { + Some(message) => vec![ + "tag".into(), + "-a".into(), + "--cleanup=verbatim".into(), + name, + "-m".into(), + message.to_string(), + target, + ], + None => vec!["tag".into(), name, target], + }; + } + "deleteTag" => return delete_tag(&root, request.name.as_deref()), "clone" => { let remote = required_text(request.remote.as_deref(), "clone source")?; let destination = required_text(request.destination.as_deref(), "clone destination")?; @@ -1483,7 +1559,7 @@ pub fn history(request: GitHistoryRequest) -> Result Result GitCommandResponse { invocations: Vec::new(), operation_error: None, stash_restore: None, + tag_deletion: None, + branch_deletion: None, warnings: Vec::new(), } } @@ -3739,6 +3817,372 @@ fn validated_branch_name(root: &str, value: Option<&str>) -> Result) -> Result { + let value = required_text(value, "tag name")?; + if is_invalid_tag_name(&value) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git tag name", + )); + } + Ok(value) +} + +/// Refname rules from `git check-ref-format` plus command-line safety guards +/// (no leading dash) shared by every Git mutation argument. +fn is_invalid_tag_name(value: &str) -> bool { + if value.starts_with('-') + || value == "@" + || value.starts_with('/') + || value.ends_with('/') + || value.ends_with('.') + || value.contains("..") + || value.contains("@{") + || value.contains("//") + { + return true; + } + if value.chars().any(|character| { + character.is_control() + || matches!(character, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\') + }) { + return true; + } + value + .split('/') + .any(|component| component.starts_with('.') || component.ends_with(".lock")) +} + +/// Reports whether `refs/tags/` already resolves, using `--verify` so +/// the probe matches the exact ref instead of any revision expression. +fn tag_exists(root: &str, name: &str) -> Result { + let probe = execute_git( + root, + &[ + "rev-parse".into(), + "--verify".into(), + "--quiet".into(), + format!("refs/tags/{name}"), + ], + None, + )?; + Ok(probe.exit_code == 0) +} + +/// Resolves a tag target revision to a commit and returns its object id. +/// Git allows tagging trees and blobs; the tag contract only promises commit +/// targets, so `^{commit}` both validates and yields the id the +/// mutation should point at. +fn resolved_commit_target(root: &str, target: &str) -> Result, CoreError> { + let probe = execute_git( + root, + &[ + "rev-parse".into(), + "--verify".into(), + "--quiet".into(), + format!("{target}^{{commit}}"), + ], + None, + )?; + Ok((probe.exit_code == 0).then(|| probe.stdout.trim().to_string())) +} + +/// Deletes one tag and returns a structured deletion record so the host can +/// offer a restore. The probes run before the deletion because `git tag -d` +/// diagnostics are localized prose that cannot be mapped to stable errors. +fn delete_tag(root: &str, value: Option<&str>) -> Result { + let name = validated_tag_name(value)?; + let reference = format!("refs/tags/{name}"); + let expected_object = resolve_ref_object(root, &reference)?.ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + format!("The tag '{name}' does not exist"), + ) + })?; + let object_type = execute_git( + root, + &["cat-file".into(), "-t".into(), expected_object.clone()], + None, + )?; + if object_type.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("The tag '{name}' does not exist"), + )); + } + let is_annotated = object_type.stdout.trim() == "tag"; + let mut message = None; + if is_annotated { + let tag_object = execute_git( + root, + &["cat-file".into(), "tag".into(), expected_object.clone()], + None, + )?; + if tag_object.exit_code == 0 { + message = annotation_message_from_tag_object(&tag_object.stdout); + } + } + // Peel the ref to a commit so pre-existing tree/blob tags cannot produce + // a recovery record that violates the restore contract. + let peeled = execute_git( + root, + &[ + "rev-parse".into(), + "--verify".into(), + format!("{expected_object}^{{commit}}"), + ], + None, + )?; + if peeled.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + format!("Could not resolve tag target '{name}'"), + ) + .with_details(peeled.output)); + } + let mut result = delete_ref_if_unchanged(root, &reference, &expected_object)?; + if result.exit_code == 0 { + result.tag_deletion = Some(GitTagDeletionResponse { + name, + deleted_target: peeled.stdout.trim().to_string(), + kind: if is_annotated { + "annotated" + } else { + "lightweight" + } + .to_string(), + message, + }); + } + Ok(result) +} + +/// Deletes one local branch and returns a structured deletion record so the +/// host can offer a restore. The commit is resolved before the deletion +/// because `git branch -d` diagnostics are localized prose. +fn delete_branch(root: &str, branch: &str) -> Result { + let reference = format!("refs/heads/{branch}"); + let target = resolve_ref_object(root, &reference)?.ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + format!("The branch '{branch}' does not exist"), + ) + })?; + ensure_branch_is_safely_deletable(root, branch, &reference, &target)?; + let mut result = delete_ref_if_unchanged(root, &reference, &target)?; + if result.exit_code == 0 { + result.branch_deletion = Some(GitBranchDeletionResponse { + name: branch.to_string(), + deleted_target: target, + }); + if let Err(error) = remove_branch_config(root, branch) { + // The ref mutation already committed. Preserve recovery data and + // report configuration cleanup as a diagnosable partial success. + result.warnings.push(GitOperationWarning::new( + "branch_config_cleanup_failed", + &error.message, + error.details, + )); + } + } + Ok(result) +} + +/// Resolves an exact refname to its current unpeeled object id. +fn resolve_ref_object(root: &str, reference: &str) -> Result, CoreError> { + let probe = execute_git( + root, + &[ + "rev-parse".into(), + "--verify".into(), + "--quiet".into(), + reference.to_string(), + ], + None, + )?; + Ok((probe.exit_code == 0).then(|| probe.stdout.trim().to_string())) +} + +/// Deletes a ref only when it still points at the object observed by the +/// caller. `update-ref` performs the comparison and mutation under the same +/// ref lock, closing the probe-then-mutate race. +fn delete_ref_if_unchanged( + root: &str, + reference: &str, + expected_object: &str, +) -> Result { + let mut result = execute_git( + root, + &[ + "update-ref".into(), + "-d".into(), + reference.to_string(), + expected_object.to_string(), + ], + None, + )?; + if result.exit_code != 0 { + result.operation_error = Some( + CoreError::new( + ErrorCode::InvalidRequest, + format!("The Git reference '{reference}' changed before it could be deleted"), + ) + .with_details(result.output.clone()), + ); + } + Ok(result) +} + +/// Preserves `git branch -d` safety before the atomic ref mutation: a branch +/// must not be checked out in any worktree and must be merged into its valid +/// upstream, or into HEAD when it has no usable upstream. +fn ensure_branch_is_safely_deletable( + root: &str, + branch: &str, + reference: &str, + target: &str, +) -> Result<(), CoreError> { + let worktrees = execute_git( + root, + &["worktree".into(), "list".into(), "--porcelain".into()], + None, + )?; + if worktrees.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Could not inspect Git worktrees") + .with_details(worktrees.output), + ); + } + if worktrees + .stdout + .lines() + .any(|line| line == format!("branch {reference}")) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("The branch '{branch}' is checked out in a worktree"), + )); + } + + let upstream = execute_git( + root, + &[ + "for-each-ref".into(), + "--format=%(upstream)".into(), + reference.to_string(), + ], + None, + )?; + if upstream.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not inspect the branch upstream", + ) + .with_details(upstream.output)); + } + let upstream = upstream.stdout.trim(); + let merge_target = if !upstream.is_empty() && resolved_commit_target(root, upstream)?.is_some() + { + upstream + } else { + "HEAD" + }; + let merged = execute_git( + root, + &[ + "merge-base".into(), + "--is-ancestor".into(), + target.to_string(), + merge_target.to_string(), + ], + None, + )?; + match merged.exit_code { + 0 => Ok(()), + 1 => Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("The branch '{branch}' is not fully merged"), + )), + _ => Err(CoreError::new( + ErrorCode::ProcessFailed, + format!("Could not verify whether branch '{branch}' is merged"), + ) + .with_details(merged.output)), + } +} + +/// Removes branch-local configuration after the ref has been deleted, matching +/// the metadata cleanup performed by `git branch -d`. +fn remove_branch_config(root: &str, branch: &str) -> Result<(), CoreError> { + let listing = capture_git_with_options( + root, + &[ + "config".into(), + "--name-only".into(), + "--get-regexp".into(), + "^branch\\.".into(), + ], + None, + false, + )?; + if listing.exit_code == 1 { + return Ok(()); + } + if listing.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not inspect branch configuration", + ) + .with_details(String::from_utf8_lossy(&listing.stderr))); + } + let prefix = format!("branch.{branch}."); + if !String::from_utf8_lossy(&listing.stdout) + .lines() + .any(|key| key.starts_with(&prefix)) + { + return Ok(()); + } + let cleanup = capture_git_with_options( + root, + &[ + "config".into(), + "--remove-section".into(), + format!("branch.{branch}"), + ], + None, + false, + )?; + if cleanup.exit_code == 0 { + return Ok(()); + } + Err(CoreError::new( + ErrorCode::ProcessFailed, + format!("Could not remove configuration for deleted branch '{branch}'"), + ) + .with_details(String::from_utf8_lossy(&cleanup.stderr))) +} + +/// Extracts the annotation message from a raw tag object byte-for-byte, so a +/// restored tag keeps the original message including CRLF line endings and +/// trailing newlines. Only the signature block is cut, by locating its first +/// line in the raw content; the signature belongs to the previous tagger and +/// a restored tag would be signed separately. +fn annotation_message_from_tag_object(raw: &str) -> Option { + let (_, message) = raw.split_once("\n\n")?; + let mut offset = 0; + for line in message.split_inclusive('\n') { + let without_eol = line.trim_end_matches(['\r', '\n']); + if without_eol.starts_with("-----BEGIN ") && without_eol.ends_with("SIGNATURE-----") { + return Some(message[..offset].to_string()); + } + offset += line.len(); + } + Some(message.to_string()) +} + fn local_branch_name(reference: &str) -> Result { let branch = reference .strip_prefix("refs/heads/") @@ -3807,6 +4251,8 @@ fn failed_git_result(error: CoreError) -> GitCommandResponse { invocations: Vec::new(), operation_error: Some(error), stash_restore: None, + tag_deletion: None, + branch_deletion: None, warnings: Vec::new(), } } @@ -4812,7 +5258,7 @@ fn switch_validated_reference( fn parse_reference(line: &str) -> Option { let columns = line.split('\t').collect::>(); - if columns.len() < 4 || columns[1].ends_with("/HEAD") { + if columns.len() < 8 || columns[1].ends_with("/HEAD") { return None; } let kind = if columns[0].starts_with("refs/heads/") { @@ -4841,6 +5287,7 @@ fn parse_reference(line: &str) -> Option { // `fullName` and always exposes the namespace-relative short name. short_name: short_name.to_string(), kind: kind.to_string(), + peels_to_commit: kind != "tag" || columns[6] == "commit" || columns[7] == "commit", is_current: columns[2].trim() == "*", upstream_short_name, ahead, @@ -5596,8 +6043,9 @@ fn relative_or_absolute(path: &Path, root: &Path) -> String { #[cfg(test)] mod tests { use super::{ - line_similarity, pair_diff_entries, parse_diff, structured_diff_from_output, DiffEntry, - GitCommandInvocation, GitCommandResponse, GitProcessOutput, MAX_ALIGNMENT_CELLS, + annotation_message_from_tag_object, line_similarity, pair_diff_entries, parse_diff, + structured_diff_from_output, DiffEntry, GitCommandInvocation, GitCommandResponse, + GitProcessOutput, MAX_ALIGNMENT_CELLS, }; use crate::protocol::{ CoreError, ErrorCode, GitCommitResponse, GitHistoryResponse, GitPushPreviewResponse, @@ -5605,6 +6053,47 @@ mod tests { }; use serde_json::Value; + #[test] + fn tag_annotation_parser_preserves_crlf_and_trailing_blank_lines() { + let raw = concat!( + "object abc123\n", + "type commit\n", + "tag v1.0\n", + "tagger Lithe Test 0 +0000\n", + "\n", + "release\r\n", + "\r\n", + "details\r\n", + "\r\n" + ); + + assert_eq!( + annotation_message_from_tag_object(raw).as_deref(), + Some("release\r\n\r\ndetails\r\n\r\n") + ); + } + + #[test] + fn tag_annotation_parser_removes_only_the_signature_block() { + let raw = concat!( + "object abc123\n", + "type commit\n", + "tag v1.0\n", + "tagger Lithe Test 0 +0000\n", + "\n", + "release\r\n", + "\r\n", + "-----BEGIN PGP SIGNATURE-----\r\n", + "signature-data\r\n", + "-----END PGP SIGNATURE-----\r\n" + ); + + assert_eq!( + annotation_message_from_tag_object(raw).as_deref(), + Some("release\r\n\r\n") + ); + } + #[cfg(target_os = "windows")] #[test] fn background_git_processes_do_not_create_windows_console() { @@ -5656,6 +6145,8 @@ mod tests { ], operation_error: None, stash_restore: None, + tag_deletion: None, + branch_deletion: None, warnings: Vec::new(), }; @@ -5835,6 +6326,8 @@ mod tests { ], operation_error: None, stash_restore: None, + tag_deletion: None, + branch_deletion: None, warnings: Vec::new(), }; @@ -5855,6 +6348,7 @@ mod tests { full_name: "refs/heads/feature/recent".into(), short_name: "feature/recent".into(), kind: "local".into(), + peels_to_commit: true, is_current: true, upstream_short_name: None, ahead: 0, @@ -5864,6 +6358,7 @@ mod tests { full_name: "refs/heads/main".into(), short_name: "main".into(), kind: "local".into(), + peels_to_commit: true, is_current: false, upstream_short_name: Some("origin/main".into()), ahead: 2, @@ -5966,6 +6461,8 @@ mod tests { "Invalid Git reference", )), stash_restore: None, + tag_deletion: None, + branch_deletion: None, warnings: Vec::new(), }; diff --git a/rust/lithe-core/src/protocol/contracts.rs b/rust/lithe-core/src/protocol/contracts.rs index eac88a01..bb5ef2fb 100644 --- a/rust/lithe-core/src/protocol/contracts.rs +++ b/rust/lithe-core/src/protocol/contracts.rs @@ -407,6 +407,9 @@ pub struct GitReferenceResponse { pub short_name: String, /// Reference category: local branch, remote branch, or tag. pub kind: String, + /// Whether the reference resolves to a commit and therefore supports + /// commit-only mutations such as restorable tag deletion. + pub peels_to_commit: bool, pub is_current: bool, pub upstream_short_name: Option, /// Commits present only on this local branch compared with its upstream. diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index f32fe04d..1793aacb 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -1512,6 +1512,479 @@ fn git_write_executes_checkout_preflight_clone_and_validation() { fs::remove_dir_all(root).expect("temporary repository should be removable"); } +#[test] +fn git_write_creates_deletes_and_records_tags() { + let root = temporary_root("git-tags"); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + fs::write(root.join("example.txt"), "initial\n").expect("file should be writable"); + assert!(run(&["add", "example.txt"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + let head = String::from_utf8_lossy(&run(&["rev-parse", "HEAD"]).stdout) + .trim() + .to_string(); + + let request = |operation: &str, payload: Value| -> Value { + let request = serde_json::json!({ + "id": operation, + "command": "git.write", + "payload": { + "root": root, + "operation": operation, + "paths": [], + "reference": null, + "referenceKind": null, + "revision": null, + "name": null, + "message": null, + "remote": null, + "destination": null, + "mode": null, + "includeUntracked": false, + "checkout": false, + "amend": false + } + }); + let mut request = request; + if let Value::Object(overrides) = payload { + for (key, value) in overrides { + request["payload"][key.as_str()] = value; + } + } + serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("write request should encode"), + )) + .expect("write response should be JSON") + }; + + // A lightweight tag points straight at the target commit and never + // carries the structured deletion record. + let lightweight = request( + "createTag", + serde_json::json!({"name": "v1.0", "revision": "HEAD"}), + ); + assert_eq!(lightweight["ok"], true, "{lightweight:?}"); + assert!(lightweight["data"].get("tagDeletion").is_none()); + assert_eq!( + String::from_utf8_lossy(&run(&["rev-parse", "refs/tags/v1.0"]).stdout).trim(), + head + ); + + // An annotation creates a tag object whose CRLF and trailing blank lines + // must survive the later delete/restore round trip byte for byte. + let annotated = request( + "createTag", + serde_json::json!({ + "name": "v2.0", + "revision": "HEAD", + "message": "release\r\n\r\nsecond paragraph\r\n\r\n" + }), + ); + assert_eq!(annotated["ok"], true, "{annotated:?}"); + assert_eq!( + String::from_utf8_lossy(&run(&["cat-file", "-t", "refs/tags/v2.0"]).stdout).trim(), + "tag" + ); + let annotated_tag_object = + String::from_utf8_lossy(&run(&["rev-parse", "refs/tags/v2.0"]).stdout) + .trim() + .to_string(); + + // An empty name is a missing required field. The format matrix below is + // shared with the macOS dialog through `tag-names.json`, so both sides + // reject exactly the same names: `git check-ref-format` refname rules, + // per-component checks such as `foo/.bar`, and the command-line guards. + // Each rejection must happen before any subprocess so the error stays a + // plain invalid_request envelope. + let tag_names: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/git/tag-names.json" + ))) + .expect("tag name fixture should be valid JSON"); + let empty = request( + "createTag", + serde_json::json!({"name": "", "revision": "HEAD"}), + ); + assert_eq!(empty["ok"], false, "{empty:?}"); + assert_eq!(empty["error"]["code"], "invalid_request"); + assert_eq!(empty["error"]["message"], "Missing or invalid Git tag name"); + for name in tag_names["invalid"].as_array().expect("invalid list") { + let name = name.as_str().expect("invalid name should be a string"); + let response = request( + "createTag", + serde_json::json!({"name": name, "revision": "HEAD"}), + ); + assert_eq!(response["ok"], false, "name {name:?}: {response:?}"); + assert_eq!( + response["error"]["code"], "invalid_request", + "name {name:?}" + ); + } + for name in tag_names["valid"].as_array().expect("valid list") { + let name = name.as_str().expect("valid name should be a string"); + let response = request( + "createTag", + serde_json::json!({"name": name, "revision": "HEAD"}), + ); + assert_eq!(response["ok"], true, "name {name:?}: {response:?}"); + } + + // A duplicate name is a structured operation error so callers can show it + // in place instead of parsing Git's stderr. + let duplicate = request( + "createTag", + serde_json::json!({"name": "v1.0", "revision": "HEAD"}), + ); + assert_eq!(duplicate["ok"], true, "{duplicate:?}"); + assert_eq!( + duplicate["data"]["operationError"]["code"], + "invalid_request" + ); + assert_eq!( + duplicate["data"]["operationError"]["message"], + "A tag named 'v1.0' already exists" + ); + + let unresolvable = request( + "createTag", + serde_json::json!({"name": "v3.0", "revision": "no-such-revision"}), + ); + assert_eq!(unresolvable["ok"], true, "{unresolvable:?}"); + assert_eq!( + unresolvable["data"]["operationError"]["code"], + "invalid_request" + ); + assert_eq!( + unresolvable["data"]["operationError"]["message"], + "Could not resolve tag target 'no-such-revision'" + ); + + // Tree and blob revisions are valid Git objects but not valid targets for + // this commit-only contract. Neither rejection may leave a tag behind. + let tree = String::from_utf8_lossy(&run(&["rev-parse", "HEAD^{tree}"]).stdout) + .trim() + .to_string(); + let blob = String::from_utf8_lossy(&run(&["hash-object", "example.txt"]).stdout) + .trim() + .to_string(); + for (name, revision) in [("tree-target", tree), ("blob-target", blob)] { + let response = request( + "createTag", + serde_json::json!({"name": name, "revision": revision}), + ); + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!( + response["data"]["operationError"]["message"], + format!("Could not resolve tag target '{revision}'") + ); + assert_ne!( + run(&[ + "rev-parse", + "--verify", + "--quiet", + &format!("refs/tags/{name}") + ]) + .status + .code(), + Some(0) + ); + } + + // Deleting a lightweight tag reports the commit it pointed at. + let delete_lightweight = request("deleteTag", serde_json::json!({"name": "v1.0"})); + assert_eq!(delete_lightweight["ok"], true, "{delete_lightweight:?}"); + assert_eq!(delete_lightweight["data"]["exitCode"], 0); + assert_eq!(delete_lightweight["data"]["tagDeletion"]["name"], "v1.0"); + assert_eq!( + delete_lightweight["data"]["tagDeletion"]["kind"], + "lightweight" + ); + assert_eq!( + delete_lightweight["data"]["tagDeletion"]["deletedTarget"], + head + ); + assert!(delete_lightweight["data"]["invocations"] + .as_array() + .expect("tag deletion invocations should be an array") + .iter() + .any(|invocation| { + invocation["arguments"] + == serde_json::json!(["update-ref", "-d", "refs/tags/v1.0", head]) + })); + assert!(delete_lightweight["data"]["tagDeletion"] + .get("message") + .is_none()); + assert!( + run(&["rev-parse", "--verify", "--quiet", "refs/tags/v1.0"]) + .status + .code() + != Some(0) + ); + + // Deleting an annotated tag carries the peeled commit and the original + // annotation so a restore can rebuild the message byte for byte. + let delete_annotated = request("deleteTag", serde_json::json!({"name": "v2.0"})); + assert_eq!(delete_annotated["ok"], true, "{delete_annotated:?}"); + let deletion = &delete_annotated["data"]["tagDeletion"]; + assert_eq!(deletion["name"], "v2.0"); + assert_eq!(deletion["kind"], "annotated"); + assert_eq!( + deletion["message"], + "release\r\n\r\nsecond paragraph\r\n\r\n" + ); + assert_eq!(deletion["deletedTarget"], head); + assert!(delete_annotated["data"]["invocations"] + .as_array() + .expect("annotated tag deletion invocations should be an array") + .iter() + .any(|invocation| { + invocation["arguments"] + == serde_json::json!(["update-ref", "-d", "refs/tags/v2.0", annotated_tag_object]) + })); + + // Deleting a missing tag fails with the stable not-exist message. + let missing = request("deleteTag", serde_json::json!({"name": "v1.0"})); + assert_eq!(missing["ok"], true, "{missing:?}"); + assert_eq!(missing["data"]["operationError"]["code"], "invalid_request"); + assert_eq!( + missing["data"]["operationError"]["message"], + "The tag 'v1.0' does not exist" + ); + + // Restoring reuses createTag with the recorded target and message, so the + // rebuilt annotation must match the original message exactly. + let restore = request( + "createTag", + serde_json::json!({ + "name": deletion["name"], + "revision": deletion["deletedTarget"], + "message": deletion["message"] + }), + ); + assert_eq!(restore["ok"], true, "{restore:?}"); + let restored_object = run(&["cat-file", "tag", "refs/tags/v2.0"]); + assert!(restored_object + .stdout + .ends_with(b"\n\nrelease\r\n\r\nsecond paragraph\r\n\r\n")); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_write_records_the_deleted_branch_target() { + let root = temporary_root("git-branch-delete"); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q", "-b", "main"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + fs::write(root.join("example.txt"), "initial\n").expect("file should be writable"); + assert!(run(&["add", "example.txt"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + assert!(run(&["branch", "feature/short-lived"]).status.success()); + assert!(run(&[ + "config", + "branch.feature/short-lived.description", + "temporary" + ]) + .status + .success()); + let head = String::from_utf8_lossy(&run(&["rev-parse", "HEAD"]).stdout) + .trim() + .to_string(); + + let request = |operation: &str, payload: Value| -> Value { + let request = serde_json::json!({ + "id": operation, + "command": "git.write", + "payload": { + "root": root, + "operation": operation, + "paths": [], + "reference": null, + "referenceKind": null, + "revision": null, + "name": null, + "message": null, + "remote": null, + "destination": null, + "mode": null, + "includeUntracked": false, + "checkout": false, + "amend": false + } + }); + let mut request = request; + if let Value::Object(overrides) = payload { + for (key, value) in overrides { + request["payload"][key.as_str()] = value; + } + } + serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("write request should encode"), + )) + .expect("write response should be JSON") + }; + + // A successful branch deletion carries the commit the branch pointed at so + // the host can offer a restore, exactly like the tag deletion record. + let deletion = request( + "deleteBranch", + serde_json::json!({"reference": "refs/heads/feature/short-lived"}), + ); + assert_eq!(deletion["ok"], true, "{deletion:?}"); + assert_eq!(deletion["data"]["exitCode"], 0); + assert_eq!( + deletion["data"]["branchDeletion"]["name"], "feature/short-lived", + "{deletion:?}" + ); + assert_eq!(deletion["data"]["branchDeletion"]["deletedTarget"], head); + assert!(deletion["data"]["invocations"] + .as_array() + .expect("branch deletion invocations should be an array") + .iter() + .any(|invocation| { + invocation["arguments"] + == serde_json::json!(["update-ref", "-d", "refs/heads/feature/short-lived", head]) + })); + assert!(deletion["data"].get("tagDeletion").is_none()); + assert_ne!( + run(&["config", "--get", "branch.feature/short-lived.description"]) + .status + .code(), + Some(0), + "branch-local configuration should be removed with the ref" + ); + + // Deleting a missing branch fails with the stable not-exist message. + let missing = request( + "deleteBranch", + serde_json::json!({"reference": "refs/heads/feature/short-lived"}), + ); + assert_eq!(missing["ok"], true, "{missing:?}"); + assert_eq!(missing["data"]["operationError"]["code"], "invalid_request"); + assert_eq!( + missing["data"]["operationError"]["message"], + "The branch 'feature/short-lived' does not exist" + ); + + // Restoring replays createBranch against the recorded commit. + let restore = request( + "createBranch", + serde_json::json!({ + "reference": deletion["data"]["branchDeletion"]["deletedTarget"], + "name": "feature/short-lived", + "checkout": false + }), + ); + assert_eq!(restore["ok"], true, "{restore:?}"); + assert_eq!( + String::from_utf8_lossy(&run(&["rev-parse", "refs/heads/feature/short-lived"]).stdout) + .trim(), + head + ); + + // A config lock can make metadata cleanup fail after the expected-OID ref + // deletion has already succeeded. The response must still carry recovery + // data so hosts can offer Restore alongside the cleanup warning. + assert!(run(&["branch", "feature/config-locked"]).status.success()); + assert!(run(&[ + "config", + "branch.feature/config-locked.description", + "temporary" + ]) + .status + .success()); + let config_lock = root.join(".git/config.lock"); + fs::write(&config_lock, "locked\n").expect("config lock should be creatable"); + let cleanup_warning = request( + "deleteBranch", + serde_json::json!({"reference": "refs/heads/feature/config-locked"}), + ); + fs::remove_file(config_lock).expect("config lock should be removable"); + assert_eq!(cleanup_warning["ok"], true, "{cleanup_warning:?}"); + assert!(cleanup_warning["data"].get("operationError").is_none()); + assert_eq!( + cleanup_warning["data"]["warnings"][0]["code"], + "branch_config_cleanup_failed" + ); + assert_eq!( + cleanup_warning["data"]["branchDeletion"]["name"], "feature/config-locked", + "{cleanup_warning:?}" + ); + assert_eq!( + cleanup_warning["data"]["branchDeletion"]["deletedTarget"], + head + ); + assert_ne!( + run(&[ + "show-ref", + "--verify", + "--quiet", + "refs/heads/feature/config-locked" + ]) + .status + .code(), + Some(0) + ); + + // Atomic deletion must retain the safety contract of `git branch -d` and + // refuse a branch whose tip is not merged into its upstream or HEAD. + assert!(run(&["switch", "-q", "feature/short-lived"]) + .status + .success()); + fs::write(root.join("feature.txt"), "unmerged\n").expect("file should be writable"); + assert!(run(&["add", "feature.txt"]).status.success()); + assert!(run(&["commit", "-qm", "unmerged branch work"]) + .status + .success()); + assert!(run(&["switch", "-q", "main"]).status.success()); + let unmerged = request( + "deleteBranch", + serde_json::json!({"reference": "refs/heads/feature/short-lived"}), + ); + assert_eq!(unmerged["ok"], true, "{unmerged:?}"); + assert_eq!( + unmerged["data"]["operationError"]["code"], + "invalid_request" + ); + assert_eq!( + unmerged["data"]["operationError"]["message"], + "The branch 'feature/short-lived' is not fully merged" + ); + assert!(run(&[ + "show-ref", + "--verify", + "--quiet", + "refs/heads/feature/short-lived" + ]) + .status + .success()); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + #[test] fn git_write_rolls_back_large_selected_path_set_without_command_line_overflow() { let root = temporary_root("git-write-large-rollback"); @@ -2317,6 +2790,16 @@ fn git_history_returns_references_and_commit_graph_fields() { assert!(run(&["add", "example.txt"]).status.success()); assert!(run(&["commit", "-qm", "initial"]).status.success()); + assert!(run(&["tag", "commit-tag", "HEAD"]).status.success()); + let tree = String::from_utf8_lossy(&run(&["rev-parse", "HEAD^{tree}"]).stdout) + .trim() + .to_string(); + assert!(run(&["tag", "tree-tag", &tree]).status.success()); + let blob = String::from_utf8_lossy(&run(&["hash-object", "example.txt"]).stdout) + .trim() + .to_string(); + assert!(run(&["tag", "blob-tag", &blob]).status.success()); + let commit_hash = String::from_utf8_lossy(&run(&["rev-parse", "HEAD"]).stdout) .trim() .to_string(); @@ -2415,6 +2898,19 @@ fn git_history_returns_references_and_commit_graph_fields() { .expect("references should be an array") .iter() .any(|reference| reference["kind"] == "local")); + for (name, expected) in [ + ("commit-tag", true), + ("tree-tag", false), + ("blob-tag", false), + ] { + let reference = response["data"]["references"] + .as_array() + .expect("references should be an array") + .iter() + .find(|reference| reference["shortName"] == name) + .expect("tag reference should be present"); + assert_eq!(reference["peelsToCommit"], expected, "tag {name}"); + } fs::remove_dir_all(root).expect("temporary workspace should be removable"); } diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 665fc1ff..8034b542 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -250,7 +250,7 @@ response retains the invocation trace and includes the failure as `renameBranch`, `setUpstream`, `unsetUpstream`, `deleteBranch`, `merge`, `rebase`, `createWorktree`, `fetch`, `pull`, `push`, `checkout`, `checkoutAndRebase`, `checkoutRevision`, `clone`, `stashPush`, `stashApply`, `stashPop`, `stashDrop`, `deleteRemoteBranch`, `operationContinue`, -`operationAbort`, and `operationSkip`. Optional fields are `paths`, `reference`, `referenceKind`, +`operationAbort`, `operationSkip`, `createTag`, and `deleteTag`. Optional fields are `paths`, `reference`, `referenceKind`, `gitReference`, `revision`, `revisions`, `name`, `message`, `remote`, `destination`, `mode`, `includeUntracked`, `checkout`, `amend`, `force`, `pushTags`, `expectedPush`, and `autoStash`. @@ -367,6 +367,38 @@ operation, a target outside the current branch's first-parent chain, a rewrite range containing a merge commit, or any rewritten commit reachable from `refs/remotes`. +`createTag` uses `name` for the new tag, `revision` as its target commit or +revision, and an optional `message`: when the field is present (including an +empty value), it creates an annotated tag (`git tag -a`); an absent field +creates a lightweight tag. UI callers trim new user-entered messages. Core +passes the supplied annotation with verbatim cleanup so restore preserves +CRLF and trailing blank lines, and an explicit empty value preserves an empty +annotated tag. Tag names must satisfy the `git check-ref-format` refname rules and must not +begin with a dash; `shared/fixtures/git/tag-names.json` pins the boundary cases +for Core and host-side validation. Before invoking Git, `createTag` probes the +repository so a duplicate tag (`A tag named '' already exists`) and an unresolvable +non-commit target (`Could not resolve tag target ''`) fail with stable +`invalid_request` messages instead of localized Git output. `deleteTag` uses +`name` and removes `refs/tags/`; a missing tag fails with +`The tag '' does not exist`. On success the response carries a +structured `tagDeletion` record — `{ "name": string, "deletedTarget": string, +"kind": "lightweight" | "annotated", "message": string? }` — where +`deletedTarget` is the peeled commit the deleted ref resolved to and +`message` is the original annotation with its line breaks preserved. Hosts +can rebuild the tag by replaying `createTag` with `name`, `deletedTarget`, +and `message`; the tagger identity and timestamp are intentionally not +preserved. Deletion supplies the observed unpeeled object ID to `update-ref`, +so a concurrent force-update fails atomically instead of deleting new state and +returning a stale recovery target. `deleteBranch` applies the same expected-OID +guard after checking the branch is fully merged and not checked out, then +on success, carries a structured `branchDeletion` record — +`{ "name": string, "deletedTarget": string }` — so hosts can offer to +recreate the branch at its previous commit; a missing branch fails with +`The branch '' does not exist`. If the ref deletion succeeds but branch +configuration cleanup fails, the response contains both `branchDeletion` and a +`branch_config_cleanup_failed` warning; hosts must preserve the Restore action while surfacing the +cleanup diagnostic. + `operationContinue`, `operationAbort`, and `operationSkip` inspect Git metadata to select the active merge, rebase, cherry-pick, or revert instead of accepting an operation kind from the caller. Continue is rejected while conflicted paths @@ -442,6 +474,8 @@ and the optional effective `userName` and `userEmail` from repository Git configuration. Commit parents are explicit so clients can render merge topology without re-parsing Git output. The identity fields let clients implement a stable `me` filter without guessing from recent commits. +Each reference includes `peelsToCommit`; hosts use it to disable commit-only +actions for legal tree/blob tags before the user reaches a failing mutation. Each local reference with an upstream also returns numeric `ahead` and `behind` counts against that fetched remote-tracking reference. References without an upstream, remote references, and tags return zero for both fields. diff --git a/shared/fixtures/git/history-response-v1.json b/shared/fixtures/git/history-response-v1.json index 595d3d0c..a5e30330 100644 --- a/shared/fixtures/git/history-response-v1.json +++ b/shared/fixtures/git/history-response-v1.json @@ -4,6 +4,7 @@ "fullName": "refs/heads/feature/recent", "shortName": "feature/recent", "kind": "local", + "peelsToCommit": true, "isCurrent": true, "upstreamShortName": null, "ahead": 0, @@ -13,6 +14,7 @@ "fullName": "refs/heads/main", "shortName": "main", "kind": "local", + "peelsToCommit": true, "isCurrent": false, "upstreamShortName": "origin/main", "ahead": 2, @@ -24,6 +26,7 @@ "fullName": "refs/heads/feature/recent", "shortName": "feature/recent", "kind": "local", + "peelsToCommit": true, "isCurrent": true, "upstreamShortName": null, "ahead": 0, @@ -33,6 +36,7 @@ "fullName": "refs/heads/main", "shortName": "main", "kind": "local", + "peelsToCommit": true, "isCurrent": false, "upstreamShortName": "origin/main", "ahead": 2, diff --git a/shared/fixtures/git/tag-names.json b/shared/fixtures/git/tag-names.json new file mode 100644 index 00000000..2aa2589d --- /dev/null +++ b/shared/fixtures/git/tag-names.json @@ -0,0 +1,35 @@ +{ + "protocolVersion": 1, + "valid": [ + "v1", + "v1.0.0", + "release-2026.08", + "feature/x", + "a/b/c", + "v1_rc1" + ], + "invalid": [ + "", + " ", + "a..b", + "-v1", + "a b", + "v~1", + "a:b", + "a?b", + "a*b", + "a[b", + "a\\b", + "@{x", + "a//b", + "a.lock", + ".hidden", + "@", + "head/", + "x/../y", + "foo/.bar", + "foo/bar.lock", + "a/.b/c", + "x/./y" + ] +} diff --git a/shared/fixtures/git/write.json b/shared/fixtures/git/write.json index e6b3cf62..ba85a07b 100644 --- a/shared/fixtures/git/write.json +++ b/shared/fixtures/git/write.json @@ -133,6 +133,51 @@ "message": "Save work", "includeUntracked": true } + }, + { + "operation": "createTag", + "payload": { + "name": "v1.0.0", + "revision": "2f1c9a4" + } + }, + { + "operation": "createTag", + "payload": { + "name": "v2.0.0", + "revision": "8e4b2d7", + "message": "Release 2.0.0" + } + }, + { + "operation": "deleteTag", + "payload": { + "name": "v2.0.0" + } + }, + { + "operation": "deleteBranch", + "payload": { + "reference": "refs/heads/feature/pending" + } + } + ], + "responses": [ + { + "operation": "deleteTag", + "tagDeletion": { + "name": "v2.0.0", + "deletedTarget": "8e4b2d7bb019f0e5a3c6f1a2d4e5b6c7d8e9f0a1", + "kind": "annotated", + "message": "Release 2.0.0" + } + }, + { + "operation": "deleteBranch", + "branchDeletion": { + "name": "feature/pending", + "deletedTarget": "2f1c9a4bb019f0e5a3c6f1a2d4e5b6c7d8e9f0a1" + } } ], "invalidRequests": [ @@ -151,6 +196,14 @@ }, "errorCode": "invalid_request" }, + { + "operation": "createTag", + "payload": { + "name": "bad tag name", + "revision": "HEAD" + }, + "errorCode": "invalid_request" + }, { "operation": "pull", "payload": { diff --git a/windows/tauri/src/features/git/api/git-branches-api.test.ts b/windows/tauri/src/features/git/api/git-branches-api.test.ts index a53646f0..f9cebb3c 100644 --- a/windows/tauri/src/features/git/api/git-branches-api.test.ts +++ b/windows/tauri/src/features/git/api/git-branches-api.test.ts @@ -31,6 +31,7 @@ describe("Git branch reference mutations", () => { fullName: "refs/remotes/origin/feature/orders", shortName: "origin/feature/orders", kind: "remote" as const, + peelsToCommit: true, isCurrent: false, }; @@ -109,6 +110,7 @@ describe("Git branch reference mutations", () => { fullName: "refs/remotes/origin/main", shortName: "origin/main", kind: "remote" as const, + peelsToCommit: true, isCurrent: false, }; await setBranchUpstream("C:/repo", "main", upstream); diff --git a/windows/tauri/src/features/git/api/git-integration-api.test.ts b/windows/tauri/src/features/git/api/git-integration-api.test.ts index 3bf87332..23ab10c6 100644 --- a/windows/tauri/src/features/git/api/git-integration-api.test.ts +++ b/windows/tauri/src/features/git/api/git-integration-api.test.ts @@ -42,6 +42,7 @@ describe("Git integration state", () => { fullName: "refs/remotes/origin/feature/demo", shortName: "origin/feature/demo", kind: "remote" as const, + peelsToCommit: true, isCurrent: false, }; @@ -123,6 +124,7 @@ describe("Git integration state", () => { fullName: "refs/remotes/origin/feature/demo", shortName: "origin/feature/demo", kind: "remote" as const, + peelsToCommit: true, isCurrent: false, }; @@ -169,6 +171,7 @@ describe("Git integration state", () => { fullName: "refs/remotes/origin/feature/demo", shortName: "origin/feature/demo", kind: "remote" as const, + peelsToCommit: true, isCurrent: false, }; @@ -200,6 +203,7 @@ describe("Git integration state", () => { fullName: "refs/remotes/origin/feature/demo", shortName: "origin/feature/demo", kind: "remote" as const, + peelsToCommit: true, isCurrent: false, }; @@ -231,6 +235,7 @@ describe("Git integration state", () => { fullName: "refs/remotes/origin/feature/demo", shortName: "origin/feature/demo", kind: "remote" as const, + peelsToCommit: true, isCurrent: false, }; diff --git a/windows/tauri/src/features/git/api/git-push-api.test.ts b/windows/tauri/src/features/git/api/git-push-api.test.ts index 95036908..56a6cf91 100644 --- a/windows/tauri/src/features/git/api/git-push-api.test.ts +++ b/windows/tauri/src/features/git/api/git-push-api.test.ts @@ -46,6 +46,7 @@ describe("Git push API", () => { fullName: "refs/heads/feature/push", shortName: "feature/push", kind: "local", + peelsToCommit: true, isCurrent: true, }); diff --git a/windows/tauri/src/features/git/api/git-remotes-api.test.ts b/windows/tauri/src/features/git/api/git-remotes-api.test.ts index e4828ca3..8c6d1acc 100644 --- a/windows/tauri/src/features/git/api/git-remotes-api.test.ts +++ b/windows/tauri/src/features/git/api/git-remotes-api.test.ts @@ -112,6 +112,7 @@ describe("Git remote Pull API", () => { fullName: "refs/remotes/team/origin/feature/orders", shortName: "team/origin/feature/orders", kind: "remote", + peelsToCommit: true, isCurrent: false, }; await deleteRemoteBranch("C:/repo", reference); diff --git a/windows/tauri/src/features/git/api/git-worktrees-api.test.ts b/windows/tauri/src/features/git/api/git-worktrees-api.test.ts index 7a858855..86b7be15 100644 --- a/windows/tauri/src/features/git/api/git-worktrees-api.test.ts +++ b/windows/tauri/src/features/git/api/git-worktrees-api.test.ts @@ -23,6 +23,7 @@ describe("Git reference worktrees", () => { fullName: "refs/remotes/origin/feature/orders", shortName: "origin/feature/orders", kind: "remote" as const, + peelsToCommit: true, isCurrent: false, }; diff --git a/windows/tauri/src/features/git/types/git.types.ts b/windows/tauri/src/features/git/types/git.types.ts index ecea27d9..11c081e6 100644 --- a/windows/tauri/src/features/git/types/git.types.ts +++ b/windows/tauri/src/features/git/types/git.types.ts @@ -34,6 +34,7 @@ export interface GitReference { fullName: string; shortName: string; kind: GitReferenceKind; + peelsToCommit: boolean; isCurrent: boolean; upstreamShortName?: string; ahead?: number; diff --git a/windows/tauri/src/features/git/utils/git-reference-actions.test.ts b/windows/tauri/src/features/git/utils/git-reference-actions.test.ts index 2b5f644e..345a45b1 100644 --- a/windows/tauri/src/features/git/utils/git-reference-actions.test.ts +++ b/windows/tauri/src/features/git/utils/git-reference-actions.test.ts @@ -18,6 +18,7 @@ const reference = ( : `refs/tags/${shortName}`, shortName, kind, + peelsToCommit: true, isCurrent, }); diff --git a/windows/tauri/src/features/git/utils/git-reference-tree.test.ts b/windows/tauri/src/features/git/utils/git-reference-tree.test.ts index 9a473069..bd8b5ca4 100644 --- a/windows/tauri/src/features/git/utils/git-reference-tree.test.ts +++ b/windows/tauri/src/features/git/utils/git-reference-tree.test.ts @@ -6,6 +6,7 @@ const reference = (shortName: string): GitReference => ({ fullName: `refs/remotes/${shortName}`, shortName, kind: "remote", + peelsToCommit: true, isCurrent: false, }); @@ -30,6 +31,7 @@ describe("Git reference tree", () => { fullName: "refs/heads/main", shortName: "main", kind: "local" as const, + peelsToCommit: true, isCurrent: true, }, ]; diff --git a/windows/tauri/src/platform/core-result-adapter.history.test.ts b/windows/tauri/src/platform/core-result-adapter.history.test.ts index 89804843..e043ac5b 100644 --- a/windows/tauri/src/platform/core-result-adapter.history.test.ts +++ b/windows/tauri/src/platform/core-result-adapter.history.test.ts @@ -13,17 +13,26 @@ describe("git history result adaptation", () => { fullName: "refs/heads/main", shortName: "main", kind: "local", + peelsToCommit: true, isCurrent: true, upstreamShortName: "origin/main", ahead: 12, behind: 3, }, + { + fullName: "refs/tags/tree-tag", + shortName: "tree-tag", + kind: "tag", + peelsToCommit: false, + isCurrent: false, + }, ], recentReferences: [ { fullName: "refs/heads/main", shortName: "main", kind: "local", + peelsToCommit: true, isCurrent: true, upstreamShortName: "origin/main", ahead: 12, @@ -52,17 +61,27 @@ describe("git history result adaptation", () => { fullName: "refs/heads/main", shortName: "main", kind: "local", + peelsToCommit: true, isCurrent: true, upstreamShortName: "origin/main", ahead: 12, behind: 3, }, + { + fullName: "refs/tags/tree-tag", + shortName: "tree-tag", + kind: "tag", + peelsToCommit: false, + isCurrent: false, + upstreamShortName: undefined, + }, ], recentReferences: [ { fullName: "refs/heads/main", shortName: "main", kind: "local", + peelsToCommit: true, isCurrent: true, upstreamShortName: "origin/main", ahead: 12, diff --git a/windows/tauri/src/platform/core-result-adapter.ts b/windows/tauri/src/platform/core-result-adapter.ts index dc28c6af..f2ef61aa 100644 --- a/windows/tauri/src/platform/core-result-adapter.ts +++ b/windows/tauri/src/platform/core-result-adapter.ts @@ -167,6 +167,7 @@ export function adaptCoreResult( fullName: reference.fullName, shortName: reference.shortName, kind: reference.kind, + peelsToCommit: Boolean(reference.peelsToCommit), isCurrent: Boolean(reference.isCurrent), upstreamShortName: reference.upstreamShortName ?? undefined, ahead: typeof reference.ahead === "number" ? reference.ahead : 0, @@ -178,6 +179,7 @@ export function adaptCoreResult( fullName: reference.fullName, shortName: reference.shortName, kind: reference.kind, + peelsToCommit: Boolean(reference.peelsToCommit), isCurrent: Boolean(reference.isCurrent), upstreamShortName: reference.upstreamShortName ?? undefined, ahead: typeof reference.ahead === "number" ? reference.ahead : 0, From 1823285b4dd6eb01b77cc8ff5fd654b20e72f40e Mon Sep 17 00:00:00 2001 From: Wz58luck <2514832692@qq.com> Date: Wed, 2 Sep 2026 10:38:23 +0800 Subject: [PATCH 2/4] fix(git): restore deleted branches from commit ids --- rust/lithe-core/src/git/mod.rs | 12 ++++++++++++ rust/lithe-core/src/tests/git.rs | 6 +++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 2dcd5eeb..4270ac23 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -2991,6 +2991,18 @@ pub(super) fn write_request_reference( root: &str, request: &GitWriteRequest, ) -> Result { + // Branch restore records carry the deleted commit object ID rather than a + // live ref. Accept that exact revision only for createBranch; every other + // mutation continues to require a typed, existing Git reference. + if request.operation == "createBranch" { + if let Some(reference) = request.git_reference.as_ref() { + if reference.full_name == reference.short_name { + if let Ok(revision) = validated_revision(Some(&reference.full_name)) { + return Ok(revision); + } + } + } + } optional_write_request_reference(root, request)? .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Missing Git reference")) } diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index 1793aacb..37f1199f 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -1893,7 +1893,11 @@ fn git_write_records_the_deleted_branch_target() { let restore = request( "createBranch", serde_json::json!({ - "reference": deletion["data"]["branchDeletion"]["deletedTarget"], + "gitReference": { + "fullName": deletion["data"]["branchDeletion"]["deletedTarget"], + "shortName": deletion["data"]["branchDeletion"]["deletedTarget"], + "kind": "local" + }, "name": "feature/short-lived", "checkout": false }), From 31d200170b4763b49bdec2e9c02076b12a8a42e6 Mon Sep 17 00:00:00 2001 From: Wz58luck <2514832692@qq.com> Date: Wed, 2 Sep 2026 10:48:29 +0800 Subject: [PATCH 3/4] fix(build): remove duplicate run state case and bound downloads --- macos/Sources/Lithe/Views/Run/RunView.swift | 6 ------ scripts/prepare-jdk.sh | 11 ++++++++++- scripts/prepare-jdtls.sh | 11 ++++++++++- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/macos/Sources/Lithe/Views/Run/RunView.swift b/macos/Sources/Lithe/Views/Run/RunView.swift index f4b238db..3ddad98e 100644 --- a/macos/Sources/Lithe/Views/Run/RunView.swift +++ b/macos/Sources/Lithe/Views/Run/RunView.swift @@ -207,12 +207,6 @@ struct RunView: View { return (String(localized: "Project identification failed"), message, "xmark.octagon.fill") case .idle: return nil - case .projectNotReady: - return ( - String(localized: "Project is still loading"), - String(localized: "Wait for the workspace scan to finish, then identify the project again."), - "hourglass" - ) } } diff --git a/scripts/prepare-jdk.sh b/scripts/prepare-jdk.sh index aed55c1d..86c08070 100755 --- a/scripts/prepare-jdk.sh +++ b/scripts/prepare-jdk.sh @@ -51,7 +51,16 @@ download_verified_file() { fi rm -f -- "$temporary_path" - if ! curl --fail --location --retry 3 --output "$temporary_path" "$url"; then + print -u2 -- "Downloading $description: $url" + if ! curl \ + --fail \ + --location \ + --retry 3 \ + --retry-all-errors \ + --connect-timeout 15 \ + --max-time 180 \ + --output "$temporary_path" \ + "$url"; then rm -f -- "$temporary_path" return 1 fi diff --git a/scripts/prepare-jdtls.sh b/scripts/prepare-jdtls.sh index cea744d0..38bf3c2e 100755 --- a/scripts/prepare-jdtls.sh +++ b/scripts/prepare-jdtls.sh @@ -80,7 +80,16 @@ download_verified_file() { fi rm -f -- "$temporary_path" - if ! curl --fail --location --retry 3 --output "$temporary_path" "$url"; then + print -u2 -- "Downloading $description: $url" + if ! curl \ + --fail \ + --location \ + --retry 3 \ + --retry-all-errors \ + --connect-timeout 15 \ + --max-time 180 \ + --output "$temporary_path" \ + "$url"; then rm -f -- "$temporary_path" return 1 fi From c2294c1ed868d8522195ad564c1e2e9d5d494072 Mon Sep 17 00:00:00 2001 From: Wz58luck <2514832692@qq.com> Date: Wed, 2 Sep 2026 15:49:25 +0800 Subject: [PATCH 4/4] fix(git): preserve recovery records after failed deletion --- .../Application/GitFeatureModel.swift | 10 +--- .../LitheGitModuleTests/GitModuleTests.swift | 55 +++++++++++++++++-- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 7f967b57..229872c7 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -1664,15 +1664,10 @@ package final class GitFeatureModel: ObservableObject { isPerformingBranchOperation = true let result = await withGitOperation { await service.deleteBranch(reference, at: gitRepositoryRoot) } isPerformingBranchOperation = false - if let deletion = result.branchDeletion { + if result.succeeded, let deletion = result.branchDeletion { recentlyDeletedBranch = deletion - notify?( - result.succeeded - ? successfulMessage(result, fallback: "Deleted branch \(deletion.name)") - : trimmedMessage(result) - ) + notify?(successfulMessage(result, fallback: "Deleted branch \(deletion.name)")) } else { - recentlyDeletedBranch = nil notify?(result.succeeded ? "Deleted \(reference.shortName)" : trimmedMessage(result)) } await refreshGit() @@ -1754,7 +1749,6 @@ package final class GitFeatureModel: ObservableObject { recentlyDeletedTag = deletion notify?("Deleted tag \(deletion.name)") } else { - recentlyDeletedTag = nil notify?(trimmedMessage(result)) } await refreshGit() diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index 219251ef..f2f5fd5e 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -655,6 +655,50 @@ struct GitModuleTests { #expect(notifications == ["The tag 'v1.0' does not exist"]) } + @Test + func gitTagDeletionFailureKeepsThePreviousRecoveryRecord() async { + var notifications: [String] = [] + let results = GitProcessResultQueue([ + GitProcessResult( + arguments: ["tag", "-d", "v1.0"], + output: "Deleted tag 'v1.0'\n", + exitCode: 0, + tagDeletion: GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .lightweight, + message: nil + ) + ), + GitProcessResult( + arguments: ["tag", "-d", "missing"], + output: "The tag 'missing' does not exist", + exitCode: 1 + ) + ]) + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + deleteTagResults: results + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + + for name in ["v1.0", "missing"] { + await feature.deleteTag(GitReference( + fullName: "refs/tags/\(name)", + shortName: name, + kind: .tag, + isCurrent: false, + upstreamShortName: nil + )) + } + + #expect(feature.recentlyDeletedTag?.name == "v1.0") + #expect(notifications == ["Deleted tag v1.0", "The tag 'missing' does not exist"]) + } + @Test func gitTagRestoreReplaysRecordedNameTargetAndMessage() async { var notifications: [String] = [] @@ -908,7 +952,7 @@ struct GitModuleTests { } @Test - func gitBranchDeletionFailureClearsThePreviousRecoveryRecord() async { + func gitBranchDeletionFailureKeepsThePreviousRecoveryRecord() async { var notifications: [String] = [] let results = GitProcessResultQueue([ GitProcessResult( @@ -949,7 +993,7 @@ struct GitModuleTests { upstreamShortName: nil )) - #expect(feature.recentlyDeletedBranch == nil) + #expect(feature.recentlyDeletedBranch?.name == "feature/a") #expect(notifications == ["Deleted branch feature/a", "The branch 'feature/b' does not exist"]) } @@ -2105,7 +2149,7 @@ private final class BranchCallRecorder: @unchecked Sendable { } } -/// Supplies deterministic per-call results for consecutive branch mutations. +/// Supplies deterministic per-call results for consecutive Git mutations. private final class GitProcessResultQueue: @unchecked Sendable { private let lock = NSLock() private var results: [GitProcessResult] @@ -2137,6 +2181,7 @@ private struct TestGitOperations: GitOperations { private let filesGate: GitFilesLoadGate? private let createTagResult: GitProcessResult? private let deleteTagResult: GitProcessResult? + private let deleteTagResults: GitProcessResultQueue? private let tagCallRecorder: TagCallRecorder? private let createBranchResult: GitProcessResult? private let deleteBranchResult: GitProcessResult? @@ -2158,6 +2203,7 @@ private struct TestGitOperations: GitOperations { filesGate: GitFilesLoadGate? = nil, createTagResult: GitProcessResult? = nil, deleteTagResult: GitProcessResult? = nil, + deleteTagResults: GitProcessResultQueue? = nil, tagCallRecorder: TagCallRecorder? = nil, createBranchResult: GitProcessResult? = nil, deleteBranchResult: GitProcessResult? = nil, @@ -2178,6 +2224,7 @@ private struct TestGitOperations: GitOperations { self.filesGate = filesGate self.createTagResult = createTagResult self.deleteTagResult = deleteTagResult + self.deleteTagResults = deleteTagResults self.tagCallRecorder = tagCallRecorder self.createBranchResult = createBranchResult self.deleteBranchResult = deleteBranchResult @@ -2281,6 +2328,6 @@ private struct TestGitOperations: GitOperations { } func deleteTag(named name: String, rootURL: URL) -> GitProcessResult? { tagCallRecorder?.record(TagCallRecorder.Call(name: name, revision: "", message: nil)) - return deleteTagResult + return deleteTagResults?.next() ?? deleteTagResult } }