From 1782c63495ce8eb7673a4cf00ca49dd96943a136 Mon Sep 17 00:00:00 2001 From: Wz58luck <2514832692@qq.com> Date: Sun, 30 Aug 2026 19:25:12 +0800 Subject: [PATCH 1/4] feat(git): support creating and deleting tags with restore - Add git.createTag / git.deleteTag to lithe-core with pre-flight validation (duplicate name, unresolvable target, missing tag) and a structured tagDeletion record carrying the peeled target and original annotation for session-scoped restore - Document both operations in rust-core-api.md and extend write.json fixtures for the second platform - macOS: New Tag dialog on commit rows with inline validation, Delete Tag confirmation on tag references, and a deleted-tag banner with restore and dismiss --- .../Lithe/Core/Rust/RustCoreBridge.swift | 8 + .../Lithe/Core/Rust/RustGitOperations.swift | 27 ++ .../AppModel/AppModel+FeatureState.swift | 3 + .../Lithe/Models/AppModel/AppModel.swift | 22 + .../Lithe/Views/Git/GitGraphView.swift | 2 + .../Sources/Lithe/Views/Git/GitLogView.swift | 404 ++++++++++++++---- .../Application/GitFeatureModel.swift | 81 ++++ .../LitheGitModule/Ports/GitPorts.swift | 26 +- .../LitheGitModule/Services/GitService.swift | 28 +- .../LitheGitModuleTests/GitModuleTests.swift | 312 +++++++++++++- rust/lithe-core/src/git/mod.rs | 214 ++++++++++ rust/lithe-core/src/tests/git.rs | 206 +++++++++ shared/contracts/rust-core-api.md | 26 +- shared/fixtures/git/write.json | 40 ++ 14 files changed, 1303 insertions(+), 96 deletions(-) diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 16772f27f..18bf702c4 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -795,6 +795,13 @@ struct RustCoreBridge: Sendable { let conflictedPaths: [String] } + struct TagDeletion: Decodable, Sendable { + let name: String + let deletedTarget: String + let kind: String + let message: String? + } + let arguments: [String]? let output: String let stdout: String? @@ -803,6 +810,7 @@ struct RustCoreBridge: Sendable { let invocations: [Invocation]? let operationError: OperationError? let stashRestore: StashRestore? + let tagDeletion: TagDeletion? } struct GitDiffPayload: Decodable, Sendable { diff --git a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift index 1fa72270a..7b003d8b2 100644 --- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift @@ -30,6 +30,14 @@ struct RustGitOperations: GitOperations, Sendable { stashReference: $0.stashReference, conflictedPaths: $0.conflictedPaths ) + }, + tagDeletion: response.tagDeletion.map { + GitTagDeletion( + name: $0.name, + deletedTarget: $0.deletedTarget, + kind: $0.kind, + message: $0.message + ) } ) } @@ -287,6 +295,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 2b0a9c53b..2da916b67 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -212,6 +212,9 @@ extension AppModel { var requestedStashReference: String? { gitFeatureIfActive?.requestedStashReference } + var recentlyDeletedTag: GitTagDeletion? { + gitFeatureIfActive?.recentlyDeletedTag + } 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 21b5f6c86..362500bc2 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -1679,6 +1679,28 @@ final class AppModel: ObservableObject, Identifiable { await gitFeature.deleteBranch(reference) } + /// 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 32e786432..2194c7930 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 { @@ -137,6 +138,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 22721d072..f4887e13c 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 = true @State private var selectedGitToolTab = GitToolTab.log @@ -64,92 +66,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) { @@ -283,6 +200,157 @@ 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 deletedTag = model.recentlyDeletedTag { + deletedTagBanner(deletedTag) + } + 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 { @@ -616,6 +684,43 @@ struct GitLogView: View { } } + /// IntelliJ-style "deleted tag [Restore]" notice. The restore record lives + /// in session state, so closing the banner ends the restore opportunity. + private func deletedTagBanner(_ deletedTag: GitTagDeletion) -> some View { + HStack(spacing: 7) { + LitheSystemIcon(systemImage: "tag", size: 13) + .foregroundStyle(LitheTheme.warning) + Text("Deleted tag '\(deletedTag.name)'") + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + Spacer(minLength: 8) + Button("Restore") { + Task { await model.restoreRecentlyDeletedTag() } + } + .controlSize(.small) + .buttonStyle(.borderedProminent) + .tint(LitheTheme.accent) + .disabled(model.isPerformingBranchOperation) + .lithePointer() + Button { + model.dismissDeletedTagBanner() + } 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) { @@ -865,6 +970,15 @@ struct GitLogView: View { } .disabled(model.isPerformingBranchOperation) } + + if reference.kind == .tag { + Divider() + + Button("Delete Tag…", role: .destructive) { + pendingTagDeletion = reference + } + .disabled(model.isPerformingBranchOperation) + } } } @@ -1153,6 +1267,9 @@ struct GitLogView: View { }, onReset: { commit in pendingOperation.wrappedValue = GitCommitOperationRequest(kind: .reset, commit: commit) + }, + onCreateTag: { commit in + tagDialogRequest = GitTagDialogRequest(commit: commit) } ) } @@ -1913,6 +2030,121 @@ 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 } + if name.contains(" ") || name.contains("~") || name.contains("^") || name.contains(":") + || name.contains("?") || name.contains("*") || name.contains("[") || name.contains("\\") { + return "A tag name cannot contain spaces or ~^:?*[\\" + "." + } + if name.hasPrefix("-") { + return "A tag name cannot start with a dash." + } + if name.contains("..") || name.hasSuffix(".") || name.hasPrefix(".") || name.hasSuffix("/") || name.contains("//") { + return "A tag name cannot contain '..' or start or end with '.', '/'." + } + if name.contains("@{") || name == "@" { + return "A tag name cannot contain '@{'." + } + if name.lowercased().hasSuffix(".lock") { + return "A tag name cannot end with '.lock'." + } + return nil + } + + 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 ebbf13f33..7732037fc 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -33,6 +33,10 @@ 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 new deletion, closing the banner, or `reset()` + /// (project close) clears it; it never persists across sessions. + @Published package private(set) var recentlyDeletedTag: GitTagDeletion? @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. @@ -162,6 +166,7 @@ package final class GitFeatureModel: ObservableObject { pendingConflictRollback = nil pendingStashRestoreConflict = nil isStashRestoreConflictNoticeVisible = false + recentlyDeletedTag = nil gitConflictFilterPaths = [] requestedStashReference = nil deferredSavedChanges = nil @@ -1537,6 +1542,82 @@ package final class GitFeatureModel: ObservableObject { await refreshGit() } + /// 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 } + 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/Ports/GitPorts.swift b/macos/Sources/LitheGitModule/Ports/GitPorts.swift index 6b07db2aa..f5cf68de5 100644 --- a/macos/Sources/LitheGitModule/Ports/GitPorts.swift +++ b/macos/Sources/LitheGitModule/Ports/GitPorts.swift @@ -21,6 +21,27 @@ public struct GitProcessInvocation: Equatable, Sendable { public var output: String { standardOutput + standardError } } +/// 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 + /// `lightweight` or `annotated`, taken from the tag object type. + public let kind: String + /// Original annotation, if any; lightweight tags carry `nil`. + public let message: String? + + public init(name: String, deletedTarget: String, kind: String, message: String?) { + self.name = name + self.deletedTarget = deletedTarget + self.kind = kind + self.message = message + } + + public var isAnnotated: Bool { kind == "annotated" } +} + public struct GitProcessResult: Sendable { public let arguments: [String] public let output: String @@ -30,6 +51,7 @@ public struct GitProcessResult: Sendable { public let invocations: [GitProcessInvocation] public let operationErrorMessage: String? public let stashRestoreConflict: GitStashRestoreConflict? + public let tagDeletion: GitTagDeletion? public init( arguments: [String] = [], output: String, @@ -38,7 +60,8 @@ public struct GitProcessResult: Sendable { exitCode: Int32, invocations: [GitProcessInvocation] = [], operationErrorMessage: String? = nil, - stashRestoreConflict: GitStashRestoreConflict? = nil + stashRestoreConflict: GitStashRestoreConflict? = nil, + tagDeletion: GitTagDeletion? = nil ) { self.arguments = arguments self.output = output @@ -48,6 +71,7 @@ public struct GitProcessResult: Sendable { self.invocations = invocations self.operationErrorMessage = operationErrorMessage self.stashRestoreConflict = stashRestoreConflict + self.tagDeletion = tagDeletion } } diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index eb1ff3d70..4c6dbbca3 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -101,6 +101,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 @@ -124,6 +126,7 @@ package struct GitService: Sendable { package let invocations: [GitProcessInvocation] package let operationErrorMessage: String? package let stashRestoreConflict: GitStashRestoreConflict? + package let tagDeletion: GitTagDeletion? package init( workingDirectory: URL? = nil, @@ -134,7 +137,8 @@ package struct GitService: Sendable { exitCode: Int32, invocations: [GitProcessInvocation] = [], operationErrorMessage: String? = nil, - stashRestoreConflict: GitStashRestoreConflict? = nil + stashRestoreConflict: GitStashRestoreConflict? = nil, + tagDeletion: GitTagDeletion? = nil ) { self.workingDirectory = workingDirectory self.arguments = arguments @@ -145,6 +149,7 @@ package struct GitService: Sendable { self.invocations = invocations self.operationErrorMessage = operationErrorMessage self.stashRestoreConflict = stashRestoreConflict + self.tagDeletion = tagDeletion } package var succeeded: Bool { @@ -614,6 +619,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] = [], @@ -633,7 +656,8 @@ package struct GitService: Sendable { exitCode: result?.exitCode ?? 1, invocations: result?.invocations ?? [], operationErrorMessage: result?.operationErrorMessage, - stashRestoreConflict: result?.stashRestoreConflict + stashRestoreConflict: result?.stashRestoreConflict, + tagDeletion: result?.tagDeletion ) }.value } diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index 3f35a37a5..8a86d9959 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -369,9 +369,273 @@ struct GitModuleTests { #expect(feature.gitConsoleEntries.first?.succeeded == true) } - @Test - func gitServicePreservesExecutedArgumentsAndWorkingDirectory() async { + // MARK: Tag management + + 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 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() + + // The restore must replay exactly the recorded deletion record so the + // rebuilt annotated tag points at the original commit with its message. + #expect(Array(recorder.recorded.suffix(2)) == [ + TagCallRecorder.Call(name: "v1.0", revision: "abc123def456", message: "release"), + 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 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) + } + + @Test + func gitServicePreservesExecutedArgumentsAndWorkingDirectory() async { let root = URL(fileURLWithPath: "/workspace") let change = GitChange( repositoryRoot: root, path: "README.md", @@ -760,6 +1024,31 @@ private final class TestGitRunGate: @unchecked Sendable { } } +/// 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 + } +} + private struct TestGitOperations: GitOperations { private let snapshotValue: GitSnapshot? private let comparisonValue: GitBranchComparison? @@ -767,6 +1056,9 @@ private struct TestGitOperations: GitOperations { private let comparisonDiffDocumentValue: DiffDocument? private let stageResult: GitProcessResult? private let runGate: TestGitRunGate? + private let createTagResult: GitProcessResult? + private let deleteTagResult: GitProcessResult? + private let tagCallRecorder: TagCallRecorder? init( snapshotValue: GitSnapshot? = nil, @@ -774,7 +1066,10 @@ private struct TestGitOperations: GitOperations { untrackedDiffDocumentValue: DiffDocument? = nil, comparisonDiffDocumentValue: DiffDocument? = nil, stageResult: GitProcessResult? = nil, - runGate: TestGitRunGate? = nil + runGate: TestGitRunGate? = nil, + createTagResult: GitProcessResult? = nil, + deleteTagResult: GitProcessResult? = nil, + tagCallRecorder: TagCallRecorder? = nil ) { self.snapshotValue = snapshotValue self.comparisonValue = comparisonValue @@ -782,6 +1077,9 @@ private struct TestGitOperations: GitOperations { self.comparisonDiffDocumentValue = comparisonDiffDocumentValue self.stageResult = stageResult self.runGate = runGate + self.createTagResult = createTagResult + self.deleteTagResult = deleteTagResult + self.tagCallRecorder = tagCallRecorder } func run(arguments: [String], workingDirectory: String, input: String?) -> GitProcessResult { @@ -842,4 +1140,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 a3d28dc38..8b78bcf10 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -113,6 +113,10 @@ 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, } /// Raw Git process streams kept separate for machine-readable consumers. @@ -142,6 +146,7 @@ impl GitProcessOutput { invocations: vec![invocation], operation_error: None, stash_restore: None, + tag_deletion: None, } } } @@ -210,6 +215,23 @@ 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` for lightweight tags and empty annotations. + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] /// Typed mutation request translated into a controlled Git invocation. @@ -578,6 +600,42 @@ fn write_with_trace(request: GitWriteRequest) -> Result { + let name = validated_tag_name(request.name.as_deref())?; + let 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"), + )); + } + if !target_resolves(&root, &target)? { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("Could not resolve tag target '{target}'"), + )); + } + let message = request + .message + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + arguments = match message { + Some(message) => vec![ + "tag".into(), + "-a".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")?; @@ -1786,6 +1844,158 @@ 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) +} + +/// Reports whether a tag target revision resolves to any object. +fn target_resolves(root: &str, target: &str) -> Result { + let probe = execute_git( + root, + &[ + "rev-parse".into(), + "--verify".into(), + "--quiet".into(), + target.to_string(), + ], + None, + )?; + Ok(probe.exit_code == 0) +} + +/// 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 object_type = execute_git( + root, + &["cat-file".into(), "-t".into(), reference.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(), reference.clone()], + None, + )?; + if tag_object.exit_code == 0 { + message = annotation_message_from_tag_object(&tag_object.stdout); + } + } + // Peel the ref so an annotated tag restores against its commit rather + // than the old tag object. + let peeled = execute_git( + root, + &[ + "rev-parse".into(), + "--verify".into(), + format!("{reference}^{{}}"), + ], + 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 = execute_git(root, &["tag".into(), "-d".into(), name.clone()], None)?; + 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) +} + +/// Extracts the annotation message from a raw tag object, preserving the +/// original line breaks. The signature block is dropped because it belongs to +/// the previous tagger; a restored tag would be signed separately. +fn annotation_message_from_tag_object(raw: &str) -> Option { + let (_, message) = raw.split_once("\n\n")?; + let message = message + .lines() + .take_while(|line| !(line.starts_with("-----BEGIN ") && line.ends_with("SIGNATURE-----"))) + .collect::>() + .join("\n"); + let message = message.trim_end(); + if message.is_empty() { + None + } else { + Some(message.to_string()) + } +} + fn local_branch_name(reference: &str) -> Result { let branch = reference .strip_prefix("refs/heads/") @@ -1854,6 +2064,7 @@ fn failed_git_result(error: CoreError) -> GitCommandResponse { invocations: Vec::new(), operation_error: Some(error), stash_restore: None, + tag_deletion: None, } } @@ -3033,6 +3244,7 @@ mod tests { ], operation_error: None, stash_restore: None, + tag_deletion: None, }; super::synchronize_final_invocation(&mut response); @@ -3211,6 +3423,7 @@ mod tests { ], operation_error: None, stash_restore: None, + tag_deletion: None, }; assert_eq!( @@ -3255,6 +3468,7 @@ mod tests { "Invalid Git reference", )), stash_restore: None, + tag_deletion: None, }; assert_eq!( diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index ac96c912b..98b81e867 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -539,6 +539,212 @@ fn git_write_validates_and_executes_shared_mutations() { 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 message must round-trip with + // its original line breaks. + let annotated = request( + "createTag", + serde_json::json!({ + "name": "v2.0", + "revision": "HEAD", + "message": "release\n\nsecond paragraph" + }), + ); + assert_eq!(annotated["ok"], true, "{annotated:?}"); + assert_eq!( + String::from_utf8_lossy(&run(&["cat-file", "-t", "refs/tags/v2.0"]).stdout).trim(), + "tag" + ); + + // An empty name is a missing required field; the format matrix below + // mirrors `git check-ref-format` plus the command-line guards every Git + // mutation argument needs. Each rejection must happen before any + // subprocess so the error stays a plain invalid_request envelope. + 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 [ + "a..b", "-v1", "a b", "v~1", "a:b", "a?b", "a*b", "a[b", "a\\b", "@{x", "a//b", "a.lock", + ".hidden", "@", "head/", + ] { + 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:?}" + ); + assert_eq!( + response["error"]["message"], "Invalid Git tag name", + "name {name:?}" + ); + } + + // 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'" + ); + + // 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"]["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\n\nsecond paragraph"); + assert_eq!(deletion["deletedTarget"], head); + + // 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"]); + let restored_object = String::from_utf8_lossy(&restored_object.stdout); + assert!(restored_object.contains("\n\nrelease\n\nsecond paragraph")); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + #[test] fn detached_worktree_context_can_publish_a_pull_request_branch() { let repository = temporary_root("detached-pr-repository"); diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 330967293..3d9c6ec27 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -218,10 +218,10 @@ response retains the invocation trace and includes the failure as `stage`, `unstage`, `discard`, `discardAll`, `stageAll`, `commit`, `cherryPick`, `revert`, `reset`, `createBranch`, `publishBranch`, `renameBranch`, `deleteBranch`, `merge`, `rebase`, `fetch`, `pull`, `push`, `checkout`, `checkoutRevision`, `clone`, `stashPush`, -`stashApply`, `stashPop`, `stashDrop`, `operationContinue`, `operationAbort`, and -`operationSkip`. Optional fields are `paths`, `reference`, `referenceKind`, -`revision`, `name`, `message`, `remote`, `destination`, `mode`, -`includeUntracked`, `checkout`, and `amend`. +`stashApply`, `stashPop`, `stashDrop`, `operationContinue`, `operationAbort`, +`operationSkip`, `createTag`, and `deleteTag`. Optional fields are `paths`, +`reference`, `referenceKind`, `revision`, `name`, `message`, `remote`, +`destination`, `mode`, `includeUntracked`, `checkout`, and `amend`. The core validates pathspecs, revisions, branch names, references, reset modes, stash references, and operation-specific required fields before invoking Git. @@ -249,6 +249,24 @@ and checks out that branch at a detached HEAD when needed, then pushes it with an upstream. If the push fails, the local branch is intentionally retained so the user can fix credentials or connectivity and retry without losing commits. +`createTag` uses `name` for the new tag, `revision` as its target commit or +revision, and an optional `message`: a trimmed, non-empty `message` creates an +annotated tag (`git tag -a`), otherwise a lightweight tag is created. Tag +names must satisfy the `git check-ref-format` refname rules and must not +begin with a dash. Before invoking Git, `createTag` probes the repository so +a duplicate tag (`A tag named '' already exists`) and an unresolvable +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. + `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 diff --git a/shared/fixtures/git/write.json b/shared/fixtures/git/write.json index e15f0e99b..0c055ac0b 100644 --- a/shared/fixtures/git/write.json +++ b/shared/fixtures/git/write.json @@ -35,6 +35,38 @@ "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" + } + } + ], + "responses": [ + { + "operation": "deleteTag", + "tagDeletion": { + "name": "v2.0.0", + "deletedTarget": "8e4b2d7bb019f0e5a3c6f1a2d4e5b6c7d8e9f0a1", + "kind": "annotated", + "message": "Release 2.0.0" + } } ], "invalidRequests": [ @@ -52,6 +84,14 @@ "paths": ["../outside.txt"] }, "errorCode": "invalid_request" + }, + { + "operation": "createTag", + "payload": { + "name": "bad tag name", + "revision": "HEAD" + }, + "errorCode": "invalid_request" } ] } From 3a8fea5b4070209791d93cf18b0eca43d213f5f0 Mon Sep 17 00:00:00 2001 From: Wz58luck <2514832692@qq.com> Date: Sun, 30 Aug 2026 19:43:40 +0800 Subject: [PATCH 2/4] feat(git): offer restore after deleting a branch - deleteBranch resolves refs/heads/ before deleting and returns a structured branchDeletion record with the previous commit, so hosts can recreate the branch without re-querying - Document the record in rust-core-api.md and extend write.json fixtures - macOS: deleted-branch banner in the Git log with restore and dismiss, sharing the deleted-tag notice UI --- .../Lithe/Core/Rust/RustCoreBridge.swift | 6 + .../Lithe/Core/Rust/RustGitOperations.swift | 6 + .../AppModel/AppModel+FeatureState.swift | 3 + .../Lithe/Models/AppModel/AppModel.swift | 9 ++ .../Sources/Lithe/Views/Git/GitLogView.swift | 32 ++++- .../Application/GitFeatureModel.swift | 45 +++++- .../LitheGitModule/Ports/GitPorts.swift | 17 ++- .../LitheGitModule/Services/GitService.swift | 8 +- .../LitheGitModuleTests/GitModuleTests.swift | 132 +++++++++++++++++- rust/lithe-core/src/git/mod.rs | 60 +++++++- rust/lithe-core/src/tests/git.rs | 104 ++++++++++++++ shared/contracts/rust-core-api.md | 6 +- shared/fixtures/git/write.json | 13 ++ 13 files changed, 425 insertions(+), 16 deletions(-) diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 18bf702c4..3bafb07cd 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -802,6 +802,11 @@ struct RustCoreBridge: Sendable { let message: String? } + struct BranchDeletion: Decodable, Sendable { + let name: String + let deletedTarget: String + } + let arguments: [String]? let output: String let stdout: String? @@ -811,6 +816,7 @@ struct RustCoreBridge: Sendable { let operationError: OperationError? let stashRestore: StashRestore? let tagDeletion: TagDeletion? + let branchDeletion: BranchDeletion? } struct GitDiffPayload: Decodable, Sendable { diff --git a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift index 7b003d8b2..179fde55b 100644 --- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift @@ -38,6 +38,12 @@ struct RustGitOperations: GitOperations, Sendable { kind: $0.kind, message: $0.message ) + }, + branchDeletion: response.branchDeletion.map { + GitBranchDeletion( + name: $0.name, + deletedTarget: $0.deletedTarget + ) } ) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index 2da916b67..eeb3f6f85 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -215,6 +215,9 @@ extension AppModel { 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 362500bc2..f087abec3 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -1679,6 +1679,15 @@ 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 diff --git a/macos/Sources/Lithe/Views/Git/GitLogView.swift b/macos/Sources/Lithe/Views/Git/GitLogView.swift index f4887e13c..ae4796797 100644 --- a/macos/Sources/Lithe/Views/Git/GitLogView.swift +++ b/macos/Sources/Lithe/Views/Git/GitLogView.swift @@ -220,8 +220,21 @@ struct GitLogView: View { 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 { - deletedTagBanner(deletedTag) + deletedReferenceBanner( + icon: "tag", + message: "Deleted tag '\(deletedTag.name)'", + onRestore: { await model.restoreRecentlyDeletedTag() }, + onDismiss: { model.dismissDeletedTagBanner() } + ) } logPanes } @@ -684,19 +697,24 @@ struct GitLogView: View { } } - /// IntelliJ-style "deleted tag [Restore]" notice. The restore record lives + /// IntelliJ-style "deleted ref [Restore]" notice. The restore record lives /// in session state, so closing the banner ends the restore opportunity. - private func deletedTagBanner(_ deletedTag: GitTagDeletion) -> some View { + private func deletedReferenceBanner( + icon: String, + message: String, + onRestore: @escaping () async -> Void, + onDismiss: @escaping () -> Void + ) -> some View { HStack(spacing: 7) { - LitheSystemIcon(systemImage: "tag", size: 13) + LitheSystemIcon(systemImage: icon, size: 13) .foregroundStyle(LitheTheme.warning) - Text("Deleted tag '\(deletedTag.name)'") + Text(message) .font(.system(size: 11.5, weight: .semibold)) .foregroundStyle(LitheTheme.primaryText) .lineLimit(1) Spacer(minLength: 8) Button("Restore") { - Task { await model.restoreRecentlyDeletedTag() } + Task { await onRestore() } } .controlSize(.small) .buttonStyle(.borderedProminent) @@ -704,7 +722,7 @@ struct GitLogView: View { .disabled(model.isPerformingBranchOperation) .lithePointer() Button { - model.dismissDeletedTagBanner() + onDismiss() } label: { Image(systemName: "xmark") .font(.system(size: 9, weight: .semibold)) diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 7732037fc..cc4b906ed 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -37,6 +37,9 @@ package final class GitFeatureModel: ObservableObject { /// offer a restore. A new deletion, closing the banner, or `reset()` /// (project close) clears it; it never persists across sessions. @Published package private(set) var recentlyDeletedTag: GitTagDeletion? + /// The most recently deleted local branch, following the same session-only + /// rules as `recentlyDeletedTag`. + @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. @@ -167,6 +170,7 @@ package final class GitFeatureModel: ObservableObject { pendingStashRestoreConflict = nil isStashRestoreConflictNoticeVisible = false recentlyDeletedTag = nil + recentlyDeletedBranch = nil gitConflictFilterPaths = [] requestedStashReference = nil deferredSavedChanges = nil @@ -1538,10 +1542,49 @@ 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 result.succeeded, let deletion = result.branchDeletion { + recentlyDeletedBranch = deletion + notify?("Deleted branch \(deletion.name)") + } else { + 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. diff --git a/macos/Sources/LitheGitModule/Ports/GitPorts.swift b/macos/Sources/LitheGitModule/Ports/GitPorts.swift index f5cf68de5..b9000e187 100644 --- a/macos/Sources/LitheGitModule/Ports/GitPorts.swift +++ b/macos/Sources/LitheGitModule/Ports/GitPorts.swift @@ -42,6 +42,18 @@ public struct GitTagDeletion: Equatable, Sendable { public var isAnnotated: Bool { kind == "annotated" } } +/// 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 GitProcessResult: Sendable { public let arguments: [String] public let output: String @@ -52,6 +64,7 @@ public struct GitProcessResult: Sendable { public let operationErrorMessage: String? public let stashRestoreConflict: GitStashRestoreConflict? public let tagDeletion: GitTagDeletion? + public let branchDeletion: GitBranchDeletion? public init( arguments: [String] = [], output: String, @@ -61,7 +74,8 @@ public struct GitProcessResult: Sendable { invocations: [GitProcessInvocation] = [], operationErrorMessage: String? = nil, stashRestoreConflict: GitStashRestoreConflict? = nil, - tagDeletion: GitTagDeletion? = nil + tagDeletion: GitTagDeletion? = nil, + branchDeletion: GitBranchDeletion? = nil ) { self.arguments = arguments self.output = output @@ -72,6 +86,7 @@ public struct GitProcessResult: Sendable { self.operationErrorMessage = operationErrorMessage self.stashRestoreConflict = stashRestoreConflict self.tagDeletion = tagDeletion + self.branchDeletion = branchDeletion } } diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index 4c6dbbca3..6527298f4 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -127,6 +127,7 @@ package struct GitService: Sendable { package let operationErrorMessage: String? package let stashRestoreConflict: GitStashRestoreConflict? package let tagDeletion: GitTagDeletion? + package let branchDeletion: GitBranchDeletion? package init( workingDirectory: URL? = nil, @@ -138,7 +139,8 @@ package struct GitService: Sendable { invocations: [GitProcessInvocation] = [], operationErrorMessage: String? = nil, stashRestoreConflict: GitStashRestoreConflict? = nil, - tagDeletion: GitTagDeletion? = nil + tagDeletion: GitTagDeletion? = nil, + branchDeletion: GitBranchDeletion? = nil ) { self.workingDirectory = workingDirectory self.arguments = arguments @@ -150,6 +152,7 @@ package struct GitService: Sendable { self.operationErrorMessage = operationErrorMessage self.stashRestoreConflict = stashRestoreConflict self.tagDeletion = tagDeletion + self.branchDeletion = branchDeletion } package var succeeded: Bool { @@ -657,7 +660,8 @@ package struct GitService: Sendable { invocations: result?.invocations ?? [], operationErrorMessage: result?.operationErrorMessage, stashRestoreConflict: result?.stashRestoreConflict, - tagDeletion: result?.tagDeletion + tagDeletion: result?.tagDeletion, + branchDeletion: result?.branchDeletion ) }.value } diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index 8a86d9959..dccf4c3e2 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -634,6 +634,93 @@ struct GitModuleTests { #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 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( @@ -1049,6 +1136,30 @@ private final class TagCallRecorder: @unchecked Sendable { } } +/// 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 + } +} + private struct TestGitOperations: GitOperations { private let snapshotValue: GitSnapshot? private let comparisonValue: GitBranchComparison? @@ -1059,6 +1170,9 @@ private struct TestGitOperations: GitOperations { private let createTagResult: GitProcessResult? private let deleteTagResult: GitProcessResult? private let tagCallRecorder: TagCallRecorder? + private let createBranchResult: GitProcessResult? + private let deleteBranchResult: GitProcessResult? + private let branchCallRecorder: BranchCallRecorder? init( snapshotValue: GitSnapshot? = nil, @@ -1069,7 +1183,10 @@ private struct TestGitOperations: GitOperations { runGate: TestGitRunGate? = nil, createTagResult: GitProcessResult? = nil, deleteTagResult: GitProcessResult? = nil, - tagCallRecorder: TagCallRecorder? = nil + tagCallRecorder: TagCallRecorder? = nil, + createBranchResult: GitProcessResult? = nil, + deleteBranchResult: GitProcessResult? = nil, + branchCallRecorder: BranchCallRecorder? = nil ) { self.snapshotValue = snapshotValue self.comparisonValue = comparisonValue @@ -1080,6 +1197,9 @@ private struct TestGitOperations: GitOperations { self.createTagResult = createTagResult self.deleteTagResult = deleteTagResult self.tagCallRecorder = tagCallRecorder + self.createBranchResult = createBranchResult + self.deleteBranchResult = deleteBranchResult + self.branchCallRecorder = branchCallRecorder } func run(arguments: [String], workingDirectory: String, input: String?) -> GitProcessResult { @@ -1116,9 +1236,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 deleteBranchResult + } func mergeBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } func rebaseCurrentBranch(onto reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } func updateCurrentBranch(at rootURL: URL, strategy: GitPullStrategy) -> GitProcessResult? { nil } diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 8b78bcf10..c7afb8466 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -117,6 +117,10 @@ pub struct GitCommandResponse { /// 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, } /// Raw Git process streams kept separate for machine-readable consumers. @@ -147,6 +151,7 @@ impl GitProcessOutput { operation_error: None, stash_restore: None, tag_deletion: None, + branch_deletion: None, } } } @@ -232,6 +237,16 @@ pub struct GitTagDeletionResponse { 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(Debug, Deserialize)] #[serde(rename_all = "camelCase")] /// Typed mutation request translated into a controlled Git invocation. @@ -552,7 +567,7 @@ fn write_with_trace(request: GitWriteRequest) -> Result { let reference = validated_reference(request.reference.as_deref())?; @@ -1978,6 +1993,45 @@ fn delete_tag(root: &str, value: Option<&str>) -> Result Result { + let target = execute_git( + root, + &[ + "rev-parse".into(), + "--verify".into(), + format!("refs/heads/{branch}"), + ], + None, + )?; + if target.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("The branch '{branch}' does not exist"), + ) + .with_details(target.output)); + } + let mut result = execute_git( + root, + &[ + "branch".into(), + "-d".into(), + "--".into(), + branch.to_string(), + ], + None, + )?; + if result.exit_code == 0 { + result.branch_deletion = Some(GitBranchDeletionResponse { + name: branch.to_string(), + deleted_target: target.stdout.trim().to_string(), + }); + } + Ok(result) +} + /// Extracts the annotation message from a raw tag object, preserving the /// original line breaks. The signature block is dropped because it belongs to /// the previous tagger; a restored tag would be signed separately. @@ -2065,6 +2119,7 @@ fn failed_git_result(error: CoreError) -> GitCommandResponse { operation_error: Some(error), stash_restore: None, tag_deletion: None, + branch_deletion: None, } } @@ -3245,6 +3300,7 @@ mod tests { operation_error: None, stash_restore: None, tag_deletion: None, + branch_deletion: None, }; super::synchronize_final_invocation(&mut response); @@ -3424,6 +3480,7 @@ mod tests { operation_error: None, stash_restore: None, tag_deletion: None, + branch_deletion: None, }; assert_eq!( @@ -3469,6 +3526,7 @@ mod tests { )), stash_restore: None, tag_deletion: None, + branch_deletion: None, }; assert_eq!( diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index 98b81e867..f4188cc20 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -745,6 +745,110 @@ fn git_write_creates_deletes_and_records_tags() { 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()); + 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" + ); + assert_eq!(deletion["data"]["branchDeletion"]["deletedTarget"], head); + assert!(deletion["data"].get("tagDeletion").is_none()); + + // 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 + ); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + #[test] fn detached_worktree_context_can_publish_a_pull_request_branch() { let repository = temporary_root("detached-pr-repository"); diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 3d9c6ec27..e973636f8 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -265,7 +265,11 @@ structured `tagDeletion` record — `{ "name": string, "deletedTarget": string, `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. +preserved. `deleteBranch` resolves `refs/heads/` before deleting and, +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`. `operationContinue`, `operationAbort`, and `operationSkip` inspect Git metadata to select the active merge, rebase, cherry-pick, or revert instead of accepting diff --git a/shared/fixtures/git/write.json b/shared/fixtures/git/write.json index 0c055ac0b..e0114254f 100644 --- a/shared/fixtures/git/write.json +++ b/shared/fixtures/git/write.json @@ -56,6 +56,12 @@ "payload": { "name": "v2.0.0" } + }, + { + "operation": "deleteBranch", + "payload": { + "reference": "refs/heads/feature/pending" + } } ], "responses": [ @@ -67,6 +73,13 @@ "kind": "annotated", "message": "Release 2.0.0" } + }, + { + "operation": "deleteBranch", + "branchDeletion": { + "name": "feature/pending", + "deletedTarget": "2f1c9a4bb019f0e5a3c6f1a2d4e5b6c7d8e9f0a1" + } } ], "invalidRequests": [ From 09dba9ed9a9253d6a6c6f4d81dadcf57a43d95b3 Mon Sep 17 00:00:00 2001 From: Wz58luck <2514832692@qq.com> Date: Sun, 30 Aug 2026 20:31:35 +0800 Subject: [PATCH 3/4] test(macos): assert tag restore replay without assuming a delete revision --- .../LitheGitModuleTests/GitModuleTests.swift | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index 136cb04d4..8fe451c64 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -577,12 +577,17 @@ struct GitModuleTests { await feature.deleteTag(reference) await feature.restoreRecentlyDeletedTag() - // The restore must replay exactly the recorded deletion record so the - // rebuilt annotated tag points at the original commit with its message. - #expect(Array(recorder.recorded.suffix(2)) == [ - TagCallRecorder.Call(name: "v1.0", revision: "abc123def456", message: "release"), - TagCallRecorder.Call(name: "v1.0", revision: "abc123def456", message: "release") - ]) + // 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"]) } From 63af49a7aae95e3c639d5f45e09a12a383e0881e Mon Sep 17 00:00:00 2001 From: Wz58luck <2514832692@qq.com> Date: Mon, 31 Aug 2026 17:20:24 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(git):=20=E4=BF=AE=E5=A4=8D=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E6=81=A2=E5=A4=8D=E5=AE=A1=E6=9F=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 限制标签目标为提交对象,并保留注释标签的原始换行。\n统一恢复状态策略、标签类型约束与跨端名称校验。\n\nRefs #354 --- .../Lithe/Core/Rust/RustCoreBridge.swift | 2 +- .../Sources/Lithe/Views/Git/GitLogView.swift | 18 +- .../Application/GitFeatureModel.swift | 15 +- .../LitheGitModule/Models/GitModels.swift | 36 +++ .../LitheGitModule/Ports/GitPorts.swift | 25 ++- macos/Tests/LitheCoreVerifier/main.swift | 18 ++ .../LitheGitModuleTests/GitModuleTests.swift | 212 +++++++++++++++++- rust/lithe-core/src/git/mod.rs | 146 +++++++++--- rust/lithe-core/src/tests/git.rs | 78 +++++-- shared/contracts/rust-core-api.md | 14 +- shared/fixtures/git/tag-names.json | 35 +++ 11 files changed, 511 insertions(+), 88 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 cf75c095b..0e15c71de 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -821,7 +821,7 @@ struct RustCoreBridge: Sendable { struct TagDeletion: Decodable, Sendable { let name: String let deletedTarget: String - let kind: String + let kind: GitTagKind let message: String? } diff --git a/macos/Sources/Lithe/Views/Git/GitLogView.swift b/macos/Sources/Lithe/Views/Git/GitLogView.swift index 534df54c4..3a23c298d 100644 --- a/macos/Sources/Lithe/Views/Git/GitLogView.swift +++ b/macos/Sources/Lithe/Views/Git/GitLogView.swift @@ -2270,23 +2270,7 @@ private struct GitTagNameDialog: View { private var validationError: String? { let name = trimmedName guard !name.isEmpty else { return nil } - if name.contains(" ") || name.contains("~") || name.contains("^") || name.contains(":") - || name.contains("?") || name.contains("*") || name.contains("[") || name.contains("\\") { - return "A tag name cannot contain spaces or ~^:?*[\\" + "." - } - if name.hasPrefix("-") { - return "A tag name cannot start with a dash." - } - if name.contains("..") || name.hasSuffix(".") || name.hasPrefix(".") || name.hasSuffix("/") || name.contains("//") { - return "A tag name cannot contain '..' or start or end with '.', '/'." - } - if name.contains("@{") || name == "@" { - return "A tag name cannot contain '@{'." - } - if name.lowercased().hasSuffix(".lock") { - return "A tag name cannot end with '.lock'." - } - return nil + return GitTagNameValidator.validationError(for: name) } private func submit() { diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index cf57f462f..22a80d28f 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -34,11 +34,12 @@ package final class GitFeatureModel: ObservableObject { @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 new deletion, closing the banner, or `reset()` - /// (project close) clears it; it never persists across sessions. + /// 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, following the same session-only - /// rules as `recentlyDeletedTag`. + /// 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? @@ -1647,6 +1648,7 @@ package final class GitFeatureModel: ObservableObject { recentlyDeletedBranch = deletion notify?("Deleted branch \(deletion.name)") } else { + recentlyDeletedBranch = nil notify?(result.succeeded ? "Deleted \(reference.shortName)" : trimmedMessage(result)) } await refreshGit() @@ -1739,6 +1741,11 @@ package final class GitFeatureModel: ObservableObject { /// 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( diff --git a/macos/Sources/LitheGitModule/Models/GitModels.swift b/macos/Sources/LitheGitModule/Models/GitModels.swift index 236b2a4a9..ff17b09cf 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 diff --git a/macos/Sources/LitheGitModule/Ports/GitPorts.swift b/macos/Sources/LitheGitModule/Ports/GitPorts.swift index b9000e187..9eeda1dee 100644 --- a/macos/Sources/LitheGitModule/Ports/GitPorts.swift +++ b/macos/Sources/LitheGitModule/Ports/GitPorts.swift @@ -21,25 +21,42 @@ 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 - /// `lightweight` or `annotated`, taken from the tag object type. - public let kind: 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: String, 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" } + 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 diff --git a/macos/Tests/LitheCoreVerifier/main.swift b/macos/Tests/LitheCoreVerifier/main.swift index 25ac0f8a2..84a97c9a2 100644 --- a/macos/Tests/LitheCoreVerifier/main.swift +++ b/macos/Tests/LitheCoreVerifier/main.swift @@ -89,6 +89,11 @@ struct CoreVerification { let expected: Expected } + private struct GitTagNamesFixture: Decodable { + let valid: [String] + let invalid: [String] + } + private static func verifySharedContractFixtures() { let searchURL = URL(fileURLWithPath: "shared/fixtures/search/basic.json") guard let searchData = try? Data(contentsOf: searchURL), @@ -165,6 +170,19 @@ struct CoreVerification { require(layout.hasMissingParents == gitFixture.expected.hasMissingParents, "Git fixture missing-parent state changed") require(mergeRow.labels.contains { $0.title == gitFixture.expected.headLabel }, "Git fixture HEAD label changed") + let tagNamesURL = URL(fileURLWithPath: "shared/fixtures/git/tag-names.json") + guard let tagNamesData = try? Data(contentsOf: tagNamesURL), + let tagNames = try? JSONDecoder().decode(GitTagNamesFixture.self, from: tagNamesData) else { + require(false, "Git tag name fixture could not be decoded") + return + } + for name in tagNames.valid { + require(GitTagNameValidator.isValid(name), "valid Git tag name was rejected: \(name)") + } + for name in tagNames.invalid { + require(!GitTagNameValidator.isValid(name), "invalid Git tag name was accepted: \(name)") + } + let commandURL = URL(fileURLWithPath: "shared/fixtures/git/command-response-v1.json") guard let commandData = try? Data(contentsOf: commandURL), let commandFixture = try? JSONDecoder().decode(GitCommandFixture.self, from: commandData) else { diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index e1764d9d4..84cfb89bc 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -472,6 +472,57 @@ struct GitModuleTests { ) } + @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] = [] @@ -522,7 +573,7 @@ struct GitModuleTests { tagDeletion: GitTagDeletion( name: "v1.0", deletedTarget: "abc123def456", - kind: "annotated", + kind: .annotated, message: "release" ) ) @@ -543,7 +594,7 @@ struct GitModuleTests { #expect(feature.recentlyDeletedTag == GitTagDeletion( name: "v1.0", deletedTarget: "abc123def456", - kind: "annotated", + kind: .annotated, message: "release" )) #expect(notifications == ["Deleted tag v1.0"]) @@ -596,7 +647,7 @@ struct GitModuleTests { tagDeletion: GitTagDeletion( name: "v1.0", deletedTarget: "abc123def456", - kind: "annotated", + kind: .annotated, message: "release" ) ), @@ -649,7 +700,7 @@ struct GitModuleTests { tagDeletion: GitTagDeletion( name: "v1.0", deletedTarget: "abc123def456", - kind: "lightweight", + kind: .lightweight, message: nil ) ) @@ -673,6 +724,46 @@ struct GitModuleTests { #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] = [] @@ -686,7 +777,7 @@ struct GitModuleTests { tagDeletion: GitTagDeletion( name: "v1.0", deletedTarget: "abc123def456", - kind: "lightweight", + kind: .lightweight, message: nil ) ) @@ -752,6 +843,95 @@ struct GitModuleTests { #expect(feature.recentlyDeletedBranch == nil) } + @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] = [] @@ -1847,6 +2027,23 @@ private final class BranchCallRecorder: @unchecked Sendable { } } +/// 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? @@ -1863,6 +2060,7 @@ private struct TestGitOperations: GitOperations { private let tagCallRecorder: TagCallRecorder? private let createBranchResult: GitProcessResult? private let deleteBranchResult: GitProcessResult? + private let deleteBranchResults: GitProcessResultQueue? private let branchCallRecorder: BranchCallRecorder? init( @@ -1881,6 +2079,7 @@ private struct TestGitOperations: GitOperations { tagCallRecorder: TagCallRecorder? = nil, createBranchResult: GitProcessResult? = nil, deleteBranchResult: GitProcessResult? = nil, + deleteBranchResults: GitProcessResultQueue? = nil, branchCallRecorder: BranchCallRecorder? = nil ) { self.snapshotValue = snapshotValue @@ -1898,6 +2097,7 @@ private struct TestGitOperations: GitOperations { self.tagCallRecorder = tagCallRecorder self.createBranchResult = createBranchResult self.deleteBranchResult = deleteBranchResult + self.deleteBranchResults = deleteBranchResults self.branchCallRecorder = branchCallRecorder } @@ -1948,7 +2148,7 @@ private struct TestGitOperations: GitOperations { func renameBranch(_ reference: GitReference, to name: String, 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 deleteBranchResult + return deleteBranchResults?.next() ?? deleteBranchResult } func mergeBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } func rebaseCurrentBranch(onto reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 14e0cce96..8b8db818f 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -238,7 +238,8 @@ pub struct GitTagDeletionResponse { pub deleted_target: String, /// `lightweight` or `annotated`, taken from the tag object type. pub kind: String, - /// Annotation message; `None` for lightweight tags and empty annotations. + /// 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, } @@ -635,7 +636,7 @@ fn write_with_trace(request: GitWriteRequest) -> Result { let name = validated_tag_name(request.name.as_deref())?; - let target = validated_revision(request.revision.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. @@ -645,21 +646,23 @@ fn write_with_trace(request: GitWriteRequest) -> Result vec![ "tag".into(), "-a".into(), + "--cleanup=verbatim".into(), name, "-m".into(), message.to_string(), @@ -2081,19 +2084,22 @@ fn tag_exists(root: &str, name: &str) -> Result { Ok(probe.exit_code == 0) } -/// Reports whether a tag target revision resolves to any object. -fn target_resolves(root: &str, target: &str) -> Result { +/// 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(), - target.to_string(), + format!("{target}^{{commit}}"), ], None, )?; - Ok(probe.exit_code == 0) + Ok((probe.exit_code == 0).then(|| probe.stdout.trim().to_string())) } /// Deletes one tag and returns a structured deletion record so the host can @@ -2125,14 +2131,14 @@ fn delete_tag(root: &str, value: Option<&str>) -> Result) -> Result Result Result Result { + let probe = execute_git( + root, + &[ + "for-each-ref".into(), + "--format=%(refname)".into(), + reference.to_string(), + ], + None, + )?; + if probe.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not verify deleted Git reference", + ) + .with_details(probe.output)); + } + // `for-each-ref` patterns can also return descendant refs, so compare the + // emitted full names instead of treating any output as an exact match. + Ok(probe.stdout.lines().any(|candidate| candidate == reference)) +} + +/// 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 message = message - .lines() - .take_while(|line| !(line.starts_with("-----BEGIN ") && line.ends_with("SIGNATURE-----"))) - .collect::>() - .join("\n"); - let message = message.trim_end(); - if message.is_empty() { - None - } else { - Some(message.to_string()) + 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 { @@ -3421,14 +3455,56 @@ 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, GitReferenceResponse, }; 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() { diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index 466e0cad5..d3ca9b009 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -683,14 +683,14 @@ fn git_write_creates_deletes_and_records_tags() { head ); - // An annotation creates a tag object whose message must round-trip with - // its original line breaks. + // 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\n\nsecond paragraph" + "message": "release\r\n\r\nsecond paragraph\r\n\r\n" }), ); assert_eq!(annotated["ok"], true, "{annotated:?}"); @@ -699,10 +699,17 @@ fn git_write_creates_deletes_and_records_tags() { "tag" ); - // An empty name is a missing required field; the format matrix below - // mirrors `git check-ref-format` plus the command-line guards every Git - // mutation argument needs. Each rejection must happen before any - // subprocess so the error stays a plain invalid_request envelope. + // 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"}), @@ -710,10 +717,8 @@ fn git_write_creates_deletes_and_records_tags() { 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 [ - "a..b", "-v1", "a b", "v~1", "a:b", "a?b", "a*b", "a[b", "a\\b", "@{x", "a//b", "a.lock", - ".hidden", "@", "head/", - ] { + 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"}), @@ -723,10 +728,14 @@ fn git_write_creates_deletes_and_records_tags() { response["error"]["code"], "invalid_request", "name {name:?}" ); - assert_eq!( - response["error"]["message"], "Invalid Git tag name", - "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 @@ -759,6 +768,37 @@ fn git_write_creates_deletes_and_records_tags() { "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:?}"); @@ -789,7 +829,10 @@ fn git_write_creates_deletes_and_records_tags() { let deletion = &delete_annotated["data"]["tagDeletion"]; assert_eq!(deletion["name"], "v2.0"); assert_eq!(deletion["kind"], "annotated"); - assert_eq!(deletion["message"], "release\n\nsecond paragraph"); + assert_eq!( + deletion["message"], + "release\r\n\r\nsecond paragraph\r\n\r\n" + ); assert_eq!(deletion["deletedTarget"], head); // Deleting a missing tag fails with the stable not-exist message. @@ -813,8 +856,9 @@ fn git_write_creates_deletes_and_records_tags() { ); assert_eq!(restore["ok"], true, "{restore:?}"); let restored_object = run(&["cat-file", "tag", "refs/tags/v2.0"]); - let restored_object = String::from_utf8_lossy(&restored_object.stdout); - assert!(restored_object.contains("\n\nrelease\n\nsecond paragraph")); + 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"); } diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 9b15b007d..9d25a70c5 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -270,12 +270,18 @@ Core safely splits it into structured remote and branch arguments and applies the requested `ffOnly`, `merge`, or `rebase` strategy. `createTag` uses `name` for the new tag, `revision` as its target commit or -revision, and an optional `message`: a trimmed, non-empty `message` creates an -annotated tag (`git tag -a`), otherwise a lightweight tag is created. Tag +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. Before invoking Git, `createTag` probes the repository so +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 -target (`Could not resolve tag target ''`) fail with stable +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 diff --git a/shared/fixtures/git/tag-names.json b/shared/fixtures/git/tag-names.json new file mode 100644 index 000000000..2aa2589dd --- /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" + ] +}