diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index f047f369..0e15c71d 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -818,6 +818,18 @@ struct RustCoreBridge: Sendable { let conflictedPaths: [String] } + struct TagDeletion: Decodable, Sendable { + let name: String + let deletedTarget: String + let kind: GitTagKind + let message: String? + } + + struct BranchDeletion: Decodable, Sendable { + let name: String + let deletedTarget: String + } + let arguments: [String]? let output: String let stdout: String? @@ -826,6 +838,8 @@ struct RustCoreBridge: Sendable { let invocations: [Invocation]? 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 cbc5f34f..6ad0127d 100644 --- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift @@ -30,6 +30,20 @@ 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 + ) + }, + branchDeletion: response.branchDeletion.map { + GitBranchDeletion( + name: $0.name, + deletedTarget: $0.deletedTarget + ) } ) } @@ -310,6 +324,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 0719d17b..1ae5d8a3 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -216,6 +216,12 @@ extension AppModel { var requestedStashReference: String? { gitFeatureIfActive?.requestedStashReference } + var recentlyDeletedTag: GitTagDeletion? { + gitFeatureIfActive?.recentlyDeletedTag + } + var recentlyDeletedBranch: GitBranchDeletion? { + gitFeatureIfActive?.recentlyDeletedBranch + } var isCommitting: Bool { gitFeatureIfActive?.isCommitting ?? false } var gitBlameLines: [URL: [GitBlameLine]] { gitFeatureIfActive?.gitBlameLines ?? [:] } var gitReferences: [GitReference] { gitFeatureIfActive?.gitReferences ?? [] } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index b1bbde89..5139acca 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -1658,6 +1658,37 @@ final class AppModel: ObservableObject, Identifiable { await gitFeature.deleteBranch(reference) } + func restoreRecentlyDeletedBranch() async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.restoreRecentlyDeletedBranch() + } + + func dismissDeletedBranchBanner() { + gitFeatureIfActive?.dismissDeletedBranchBanner() + } + + /// Returns nil on success, otherwise the error message a tag dialog + /// should show where the user typed. + @discardableResult + func createTag(at commit: GitCommit, name: String, message: String) async -> String? { + guard let gitFeature = await activateGitModule() else { return "No Git repository is open" } + return await gitFeature.createTag(at: commit, name: name, message: message) + } + + func deleteTag(_ reference: GitReference) async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.deleteTag(reference) + } + + func restoreRecentlyDeletedTag() async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.restoreRecentlyDeletedTag() + } + + func dismissDeletedTagBanner() { + gitFeatureIfActive?.dismissDeletedTagBanner() + } + func mergeBranch(_ reference: GitReference) async { guard let gitFeature = await activateGitModule() else { return } await gitFeature.mergeBranch(reference) diff --git a/macos/Sources/Lithe/Views/Git/GitGraphView.swift b/macos/Sources/Lithe/Views/Git/GitGraphView.swift index 55769df1..01c9bbbf 100644 --- a/macos/Sources/Lithe/Views/Git/GitGraphView.swift +++ b/macos/Sources/Lithe/Views/Git/GitGraphView.swift @@ -11,6 +11,7 @@ struct GitGraphRowActions { let onCherryPick: (GitCommit) -> Void let onRevert: (GitCommit) -> Void let onReset: (GitCommit) -> Void + let onCreateTag: (GitCommit) -> Void } struct GitGraphView: View { @@ -136,6 +137,7 @@ private struct GitGraphRowView: View, Equatable { NSPasteboard.general.setString(row.commit.shortHash, forType: .string) } Divider() + Button("New Tag…") { actions.onCreateTag(row.commit) } Button("Cherry-pick Commit…") { actions.onCherryPick(row.commit) } Button("Revert Commit…") { actions.onRevert(row.commit) } Button("Reset Current Branch to Here…") { actions.onReset(row.commit) } diff --git a/macos/Sources/Lithe/Views/Git/GitLogView.swift b/macos/Sources/Lithe/Views/Git/GitLogView.swift index 99ee19b7..3a23c298 100644 --- a/macos/Sources/Lithe/Views/Git/GitLogView.swift +++ b/macos/Sources/Lithe/Views/Git/GitLogView.swift @@ -18,9 +18,11 @@ struct GitLogView: View { @State private var filesPaneHeight: CGFloat? @State private var filesPaneDragStart: CGFloat = 0 @State private var branchDialogRequest: GitBranchDialogRequest? + @State private var tagDialogRequest: GitTagDialogRequest? @State private var pendingPushReference: GitReference? @State private var pendingCommitOperation: GitCommitOperationRequest? @State private var pendingBranchOperation: GitBranchOperationRequest? + @State private var pendingTagDeletion: GitReference? @State private var comparisonSourceReference: GitReference? @State private var showCommitDecorations = false @State private var selectedGitToolTab = GitToolTab.log @@ -69,92 +71,7 @@ struct GitLogView: View { var body: some View { VStack(spacing: 0) { toolWindowHeader - if selectedGitToolTab == .log { - primaryActionBar - - GeometryReader { geometry in - let minimumReferencePaneWidth: CGFloat = 220 - let minimumCommitPaneWidth: CGFloat = 340 - let minimumDetailPaneWidth: CGFloat = 280 - let availablePaneWidth = max( - 0, - geometry.size.width - (SplitHandleView.thickness * 2) - ) - let maximumDetailPaneWidth = max( - minimumDetailPaneWidth, - min(520, availablePaneWidth - minimumReferencePaneWidth - minimumCommitPaneWidth) - ) - let resolvedDetailPaneWidth = constrained( - detailPaneWidth, - minimum: minimumDetailPaneWidth, - maximum: maximumDetailPaneWidth - ) - let maximumReferencePaneWidth = max( - minimumReferencePaneWidth, - min(480, availablePaneWidth - resolvedDetailPaneWidth - minimumCommitPaneWidth) - ) - let resolvedReferencePaneWidth = constrained( - referencePaneWidth, - minimum: minimumReferencePaneWidth, - maximum: maximumReferencePaneWidth - ) - - HStack(spacing: 0) { - referencePane - .frame(width: resolvedReferencePaneWidth) - - SplitHandleView( - axis: .horizontal, - onDragStarted: { - referencePaneDragStart = resolvedReferencePaneWidth - }, - onDragChanged: { translation in - referencePaneWidth = constrained( - referencePaneDragStart + translation, - minimum: minimumReferencePaneWidth, - maximum: maximumReferencePaneWidth - ) - }, - onDragEnded: { translation in - referencePaneWidth = constrained( - referencePaneDragStart + translation, - minimum: minimumReferencePaneWidth, - maximum: maximumReferencePaneWidth - ) - } - ) - - commitPane - .frame(minWidth: minimumCommitPaneWidth, maxWidth: .infinity) - - SplitHandleView( - axis: .horizontal, - onDragStarted: { - detailPaneDragStart = resolvedDetailPaneWidth - }, - onDragChanged: { translation in - detailPaneWidth = constrained( - detailPaneDragStart - translation, - minimum: minimumDetailPaneWidth, - maximum: maximumDetailPaneWidth - ) - }, - onDragEnded: { translation in - detailPaneWidth = constrained( - detailPaneDragStart - translation, - minimum: minimumDetailPaneWidth, - maximum: maximumDetailPaneWidth - ) - } - ) - - detailPane - .frame(width: resolvedDetailPaneWidth) - } - } - } else { - gitConsolePane - } + primaryContent } .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.sidebar) .task(id: model.gitCommits) { @@ -302,6 +219,170 @@ struct GitLogView: View { Text(operation.kind.message(for: operation.reference)) } } + .modifier(GitTagDialogsModifier( + tagDialogRequest: $tagDialogRequest, + pendingTagDeletion: $pendingTagDeletion + )) + } + + /// The tab split lives outside `body` because the main expression is + /// already close to the type-checker limit. + @ViewBuilder + private var primaryContent: some View { + if selectedGitToolTab == .log { + logTabContent + } else { + gitConsolePane + } + } + + private var logTabContent: some View { + Group { + primaryActionBar + if let deletedBranch = model.recentlyDeletedBranch { + deletedReferenceBanner( + icon: "arrow.triangle.branch", + message: "Deleted branch '\(deletedBranch.name)'", + onRestore: { await model.restoreRecentlyDeletedBranch() }, + onDismiss: { model.dismissDeletedBranchBanner() } + ) + } + if let deletedTag = model.recentlyDeletedTag { + deletedReferenceBanner( + icon: "tag", + message: "Deleted tag '\(deletedTag.name)'", + onRestore: { await model.restoreRecentlyDeletedTag() }, + onDismiss: { model.dismissDeletedTagBanner() } + ) + } + logPanes + } + } + + private var logPanes: some View { + GeometryReader { geometry in + let minimumReferencePaneWidth: CGFloat = 220 + let minimumCommitPaneWidth: CGFloat = 340 + let minimumDetailPaneWidth: CGFloat = 280 + let availablePaneWidth = max( + 0, + geometry.size.width - (SplitHandleView.thickness * 2) + ) + let maximumDetailPaneWidth = max( + minimumDetailPaneWidth, + min(520, availablePaneWidth - minimumReferencePaneWidth - minimumCommitPaneWidth) + ) + let resolvedDetailPaneWidth = constrained( + detailPaneWidth, + minimum: minimumDetailPaneWidth, + maximum: maximumDetailPaneWidth + ) + let maximumReferencePaneWidth = max( + minimumReferencePaneWidth, + min(480, availablePaneWidth - resolvedDetailPaneWidth - minimumCommitPaneWidth) + ) + let resolvedReferencePaneWidth = constrained( + referencePaneWidth, + minimum: minimumReferencePaneWidth, + maximum: maximumReferencePaneWidth + ) + + HStack(spacing: 0) { + referencePane + .frame(width: resolvedReferencePaneWidth) + + SplitHandleView( + axis: .horizontal, + onDragStarted: { + referencePaneDragStart = resolvedReferencePaneWidth + }, + onDragChanged: { translation in + referencePaneWidth = constrained( + referencePaneDragStart + translation, + minimum: minimumReferencePaneWidth, + maximum: maximumReferencePaneWidth + ) + }, + onDragEnded: { translation in + referencePaneWidth = constrained( + referencePaneDragStart + translation, + minimum: minimumReferencePaneWidth, + maximum: maximumReferencePaneWidth + ) + } + ) + + commitPane + .frame(minWidth: minimumCommitPaneWidth, maxWidth: .infinity) + + SplitHandleView( + axis: .horizontal, + onDragStarted: { + detailPaneDragStart = resolvedDetailPaneWidth + }, + onDragChanged: { translation in + detailPaneWidth = constrained( + detailPaneDragStart - translation, + minimum: minimumDetailPaneWidth, + maximum: maximumDetailPaneWidth + ) + }, + onDragEnded: { translation in + detailPaneWidth = constrained( + detailPaneDragStart - translation, + minimum: minimumDetailPaneWidth, + maximum: maximumDetailPaneWidth + ) + } + ) + + detailPane + .frame(width: resolvedDetailPaneWidth) + } + } + } + + /// The New Tag sheet and its delete confirmation live in a modifier + /// because the main `body` expression is already close to the type-checker + /// limit; an explicit `ViewModifier` keeps both type-checkable. + private struct GitTagDialogsModifier: ViewModifier { + @Binding var tagDialogRequest: GitTagDialogRequest? + @Binding var pendingTagDeletion: GitReference? + @EnvironmentObject private var model: AppModel + + func body(content: Content) -> some View { + content + .sheet(item: $tagDialogRequest) { request in + GitTagNameDialog(request: request) { name, message in + // Returning the failure keeps the dialog open so the + // error appears where the user typed, like IntelliJ's + // New Tag dialog. + await model.createTag(at: request.commit, name: name, message: message) + } + } + .confirmationDialog( + "Delete tag '\(pendingTagDeletion?.shortName ?? "")'?", + isPresented: Binding( + get: { pendingTagDeletion != nil }, + set: { if !$0 { pendingTagDeletion = nil } } + ), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + guard let reference = pendingTagDeletion else { return } + pendingTagDeletion = nil + Task { await model.deleteTag(reference) } + } + .disabled(model.isPerformingBranchOperation) + .lithePointer() + Button("Cancel", role: .cancel) { + pendingTagDeletion = nil + } + .lithePointer() + } message: { + Text("This removes the tag from the repository and affects collaborators who reference it. You can restore it from the banner afterwards.") + } + } } private var toolWindowHeader: some View { @@ -643,6 +724,48 @@ struct GitLogView: View { } } + /// IntelliJ-style "deleted ref [Restore]" notice. The restore record lives + /// in session state, so closing the banner ends the restore opportunity. + private func deletedReferenceBanner( + icon: String, + message: String, + onRestore: @escaping () async -> Void, + onDismiss: @escaping () -> Void + ) -> some View { + HStack(spacing: 7) { + LitheSystemIcon(systemImage: icon, size: 13) + .foregroundStyle(LitheTheme.warning) + Text(message) + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + Spacer(minLength: 8) + Button("Restore") { + Task { await onRestore() } + } + .controlSize(.small) + .buttonStyle(.borderedProminent) + .tint(LitheTheme.accent) + .disabled(model.isPerformingBranchOperation) + .lithePointer() + Button { + onDismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(LitheTheme.secondaryText) + } + .litheIconButton() + .help("Dismiss") + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(LitheTheme.raised) + .overlay(alignment: .bottom) { + Rectangle().fill(LitheTheme.divider).frame(height: 1) + } + } + private var referencePane: some View { VStack(spacing: 0) { HStack(spacing: 4) { @@ -932,6 +1055,15 @@ struct GitLogView: View { } .disabled(model.isPerformingBranchOperation) } + + if reference.kind == .tag { + Divider() + + Button("Delete Tag…", role: .destructive) { + pendingTagDeletion = reference + } + .disabled(model.isPerformingBranchOperation) + } } } @@ -1282,6 +1414,9 @@ struct GitLogView: View { }, onReset: { commit in pendingOperation.wrappedValue = GitCommitOperationRequest(kind: .reset, commit: commit) + }, + onCreateTag: { commit in + tagDialogRequest = GitTagDialogRequest(commit: commit) } ) } @@ -2055,6 +2190,105 @@ private struct GitBranchNameDialog: View { } } +private struct GitTagDialogRequest: Identifiable { + let id = UUID() + let commit: GitCommit +} + +/// New Tag dialog mirroring IntelliJ's: a required name plus an optional +/// message (annotated tag when non-empty). Local validation shows inline and +/// keeps the dialog open; a server-side failure returned by `onSubmit` (for +/// example a duplicate name) is shown here as well instead of a notification. +private struct GitTagNameDialog: View { + @Environment(\.dismiss) private var dismiss + let request: GitTagDialogRequest + let onSubmit: (String, String) async -> String? + + @State private var name = "" + @State private var message = "" + @State private var submitError: String? + @State private var isSubmitting = false + @FocusState private var nameFieldFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 5) { + Text("New Tag") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + Text("Create on commit \(request.commit.shortHash). Leave the message empty for a lightweight tag.") + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + } + + TextField("Tag name", text: $name) + .textFieldStyle(.roundedBorder) + .focused($nameFieldFocused) + .onSubmit(submit) + + VStack(alignment: .leading, spacing: 3) { + TextField("Message (optional)", text: $message, axis: .vertical) + .textFieldStyle(.roundedBorder) + .lineLimit(1...4) + Text("A message creates an annotated tag.") + .font(.system(size: 10.5)) + .foregroundStyle(LitheTheme.secondaryText) + } + + if let error = validationError ?? submitError { + Text(error) + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.error) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + .lithePointer() + Button("Create", action: submit) + .buttonStyle(.borderedProminent) + .lithePointer() + .tint(LitheTheme.accent) + .keyboardShortcut(.defaultAction) + .disabled(trimmedName.isEmpty || validationError != nil || isSubmitting) + } + } + .padding(20) + .frame(width: 420) + .background(LitheTheme.raised) + .onAppear { nameFieldFocused = true } + } + + private var trimmedName: String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Mirrors the refname rules the Rust core enforces so illegal names are + /// rejected before a request is sent. + private var validationError: String? { + let name = trimmedName + guard !name.isEmpty else { return nil } + return GitTagNameValidator.validationError(for: name) + } + + private func submit() { + guard !trimmedName.isEmpty, validationError == nil, !isSubmitting else { return } + isSubmitting = true + submitError = nil + Task { + let error = await onSubmit(trimmedName, message) + isSubmitting = false + if let error { + submitError = error + } else { + dismiss() + } + } + } +} + /// Offered when local changes would be overwritten by a checkout, so the user can pick a /// resolution instead of being handed Git's raw refusal. /// Offers to stash when uncommitted changes block a merge or rebase. diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index bc4bd629..22a80d28 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -33,6 +33,14 @@ package final class GitFeatureModel: ObservableObject { @Published package var pendingConflictRollback: GitConflictRollbackRequest? @Published package private(set) var pendingStashRestoreConflict: GitStashRestoreConflictRequest? @Published package private(set) var isStashRestoreConflictNoticeVisible = false + /// The most recently deleted tag, kept in session state so the Git log can + /// offer a restore. A later tag deletion replaces it on success or clears + /// it on failure; branch recovery is independent, so both banners may be + /// visible. Closing the banner or `reset()` clears this session-only state. + @Published package private(set) var recentlyDeletedTag: GitTagDeletion? + /// The most recently deleted local branch. Later branch attempts follow the + /// same replace-or-clear rule without changing tag recovery state. + @Published package private(set) var recentlyDeletedBranch: GitBranchDeletion? @Published package private(set) var gitConflictFilterPaths: Set = [] @Published package private(set) var requestedStashReference: String? /// Set whenever Git is mid-merge, mid-rebase, mid-cherry-pick, or mid-revert. @@ -170,6 +178,8 @@ package final class GitFeatureModel: ObservableObject { pendingConflictRollback = nil pendingStashRestoreConflict = nil isStashRestoreConflictNoticeVisible = false + recentlyDeletedTag = nil + recentlyDeletedBranch = nil gitConflictFilterPaths = [] requestedStashReference = nil deferredSavedChanges = nil @@ -1634,10 +1644,131 @@ 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 { + recentlyDeletedBranch = nil + notify?(result.succeeded ? "Deleted \(reference.shortName)" : trimmedMessage(result)) + } + await refreshGit() + } + + /// Rebuilds the deleted branch at its recorded commit. A failure (for + /// example the name was re-created elsewhere) keeps the record so the user + /// can retry or close the banner themselves. + package func restoreRecentlyDeletedBranch() async { + guard let deletion = recentlyDeletedBranch, let gitRepositoryRoot else { return } + isPerformingBranchOperation = true + let result = await withGitOperation { + await service.createBranch( + named: deletion.name, + from: GitReference( + fullName: deletion.deletedTarget, + shortName: deletion.deletedTarget, + kind: .local, + isCurrent: false, + upstreamShortName: nil + ), + checkout: false, + at: gitRepositoryRoot + ) + } + isPerformingBranchOperation = false + if result.succeeded { + recentlyDeletedBranch = nil + notify?("Restored branch \(deletion.name)") + await refreshGit() + } else { + notify?(trimmedMessage(result)) + } + } + + package func dismissDeletedBranchBanner() { + recentlyDeletedBranch = nil + } + + /// Creates a lightweight or annotated tag. Returns `nil` on success so a + /// dialog can stay open and show the failure where the user typed; the + /// caller decides whether to surface the returned message itself. + @discardableResult + package func createTag( + at commit: GitCommit, + name rawName: String, + message: String + ) async -> String? { + guard let gitRepositoryRoot else { return "No Git repository is open" } + let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return "Enter a tag name" } + let annotation = message.trimmingCharacters(in: .whitespacesAndNewlines) + isPerformingBranchOperation = true + let result = await withGitOperation { + await service.createTag( + named: name, + at: commit.hash, + message: annotation.isEmpty ? nil : annotation, + at: gitRepositoryRoot + ) + } + isPerformingBranchOperation = false + if result.succeeded { + notify?("Created tag \(name)") + await refreshGit() + return nil + } + return trimmedMessage(result) + } + + package func deleteTag(_ reference: GitReference) async { + guard let gitRepositoryRoot else { return } + isPerformingBranchOperation = true + let result = await withGitOperation { + await service.deleteTag(named: reference.shortName, at: gitRepositoryRoot) + } + isPerformingBranchOperation = false + if result.succeeded, let deletion = result.tagDeletion { + recentlyDeletedTag = deletion + notify?("Deleted tag \(deletion.name)") + } else { + recentlyDeletedTag = nil + notify?(trimmedMessage(result)) + } await refreshGit() } + /// Rebuilds the deleted tag at its recorded commit. A failure (for example + /// the name was re-created elsewhere) keeps the record so the user can + /// retry or close the banner themselves. + package func restoreRecentlyDeletedTag() async { + guard let deletion = recentlyDeletedTag, let gitRepositoryRoot else { return } + guard deletion.hasConsistentKindAndMessage else { + recentlyDeletedTag = nil + notify?("The deleted tag recovery record is invalid") + return + } + isPerformingBranchOperation = true + let result = await withGitOperation { + await service.createTag( + named: deletion.name, + at: deletion.deletedTarget, + message: deletion.message, + at: gitRepositoryRoot + ) + } + isPerformingBranchOperation = false + if result.succeeded { + recentlyDeletedTag = nil + notify?("Restored tag \(deletion.name)") + await refreshGit() + } else { + notify?(trimmedMessage(result)) + } + } + + package func dismissDeletedTagBanner() { + recentlyDeletedTag = nil + } + /// Records the merge or rebase commit Git is waiting on once its conflicts are /// resolved. Rust refuses while any file is still conflicted, so the failure /// message names what is left. diff --git a/macos/Sources/LitheGitModule/Models/GitModels.swift b/macos/Sources/LitheGitModule/Models/GitModels.swift index 236b2a4a..ff17b09c 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 6b07db2a..9eeda1de 100644 --- a/macos/Sources/LitheGitModule/Ports/GitPorts.swift +++ b/macos/Sources/LitheGitModule/Ports/GitPorts.swift @@ -21,6 +21,56 @@ public struct GitProcessInvocation: Equatable, Sendable { public var output: String { standardOutput + standardError } } +/// The two tag object forms supported by the shared Git contract. +public enum GitTagKind: String, Codable, Sendable { + case lightweight + case annotated +} + +/// Everything a host needs to rebuild a deleted tag later: the record is kept +/// in session state only, and restores replay `createTag` with these values. +public struct GitTagDeletion: Equatable, Sendable { + public let name: String + /// The commit the deleted ref resolved to (peeled for annotated tags). + public let deletedTarget: String + /// Tag form taken from the deleted ref's object type. + public let kind: GitTagKind + /// Original annotation, if any; lightweight tags carry `nil`. + public let message: String? + + public init(name: String, deletedTarget: String, kind: GitTagKind, message: String?) { + self.name = name + self.deletedTarget = deletedTarget + self.kind = kind + self.message = message + } + + public var isAnnotated: Bool { kind == .annotated } + + /// A lightweight tag has no annotation, while an annotated tag always + /// carries a message value (which may be empty) so restore preserves form. + public var hasConsistentKindAndMessage: Bool { + switch kind { + case .lightweight: + message == nil + case .annotated: + message != nil + } + } +} + +/// A deleted local branch and the commit it pointed at, kept in session state +/// so the host can offer a restore. +public struct GitBranchDeletion: Equatable, Sendable { + public let name: String + public let deletedTarget: String + + public init(name: String, deletedTarget: String) { + self.name = name + self.deletedTarget = deletedTarget + } +} + public struct GitProcessResult: Sendable { public let arguments: [String] public let output: String @@ -30,6 +80,8 @@ public struct GitProcessResult: Sendable { public let invocations: [GitProcessInvocation] public let operationErrorMessage: String? public let stashRestoreConflict: GitStashRestoreConflict? + public let tagDeletion: GitTagDeletion? + public let branchDeletion: GitBranchDeletion? public init( arguments: [String] = [], output: String, @@ -38,7 +90,9 @@ public struct GitProcessResult: Sendable { exitCode: Int32, invocations: [GitProcessInvocation] = [], operationErrorMessage: String? = nil, - stashRestoreConflict: GitStashRestoreConflict? = nil + stashRestoreConflict: GitStashRestoreConflict? = nil, + tagDeletion: GitTagDeletion? = nil, + branchDeletion: GitBranchDeletion? = nil ) { self.arguments = arguments self.output = output @@ -48,6 +102,8 @@ public struct GitProcessResult: Sendable { self.invocations = invocations 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 2c5d23bc..3e35ecd6 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -107,6 +107,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 @@ -130,6 +132,8 @@ package struct GitService: Sendable { package let invocations: [GitProcessInvocation] package let operationErrorMessage: String? package let stashRestoreConflict: GitStashRestoreConflict? + package let tagDeletion: GitTagDeletion? + package let branchDeletion: GitBranchDeletion? package init( workingDirectory: URL? = nil, @@ -140,7 +144,9 @@ package struct GitService: Sendable { exitCode: Int32, invocations: [GitProcessInvocation] = [], operationErrorMessage: String? = nil, - stashRestoreConflict: GitStashRestoreConflict? = nil + stashRestoreConflict: GitStashRestoreConflict? = nil, + tagDeletion: GitTagDeletion? = nil, + branchDeletion: GitBranchDeletion? = nil ) { self.workingDirectory = workingDirectory self.arguments = arguments @@ -151,6 +157,8 @@ package struct GitService: Sendable { self.invocations = invocations self.operationErrorMessage = operationErrorMessage self.stashRestoreConflict = stashRestoreConflict + self.tagDeletion = tagDeletion + self.branchDeletion = branchDeletion } package var succeeded: Bool { @@ -634,6 +642,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] = [], @@ -653,7 +679,9 @@ package struct GitService: Sendable { exitCode: result?.exitCode ?? 1, invocations: result?.invocations ?? [], operationErrorMessage: result?.operationErrorMessage, - stashRestoreConflict: result?.stashRestoreConflict + stashRestoreConflict: result?.stashRestoreConflict, + tagDeletion: result?.tagDeletion, + branchDeletion: result?.branchDeletion ) }.value } diff --git a/macos/Tests/LitheCoreVerifier/main.swift b/macos/Tests/LitheCoreVerifier/main.swift index 25ac0f8a..84a97c9a 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 0735266f..84cfb89b 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -441,9 +441,545 @@ 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 gitTagNameValidationMatchesTheSharedContractFixture() throws { + struct TagNames: Decodable { + let valid: [String] + let invalid: [String] + } + + let fixtureURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // LitheGitModuleTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // macos + .deletingLastPathComponent() // repository root + .appendingPathComponent("shared/fixtures/git/tag-names.json") + let fixture = try JSONDecoder().decode(TagNames.self, from: Data(contentsOf: fixtureURL)) + + for name in fixture.valid { + #expect(GitTagNameValidator.isValid(name), "expected valid tag name: \(name)") + } + for name in fixture.invalid { + #expect(!GitTagNameValidator.isValid(name), "expected invalid tag name: \(name)") + } + } + + @Test + func gitTagDeletionRequiresKindAndMessageToDescribeTheSameTagForm() { + #expect(GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .lightweight, + message: nil + ).hasConsistentKindAndMessage) + #expect(GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .annotated, + message: "" + ).hasConsistentKindAndMessage) + #expect(!GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .lightweight, + message: "release" + ).hasConsistentKindAndMessage) + #expect(!GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .annotated, + message: nil + ).hasConsistentKindAndMessage) + } + + @Test + func gitTagCreationSucceedsSilentlyForTheDialogAndNotifiesOnSuccess() async { + var notifications: [String] = [] + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + createTagResult: GitProcessResult(arguments: ["tag", "v1.0", "abc123def456"], output: "", exitCode: 0) + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + + // An empty result would mean the dialog shows a generic failure, so a + // successful create must return nil and notify instead. + let error = await feature.createTag(at: makeTagCommit(), name: "v1.0", message: "") + + #expect(error == nil) + #expect(notifications == ["Created tag v1.0"]) + } + + @Test + func gitTagCreationReturnsTheFailureToTheDialogWithoutNotifying() async { + var notifications: [String] = [] + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []) + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + + let error = await feature.createTag(at: makeTagCommit(), name: "v1.0", message: "") + + #expect(error == "Rust Core Git operation failed") + #expect(notifications.isEmpty) + } + + @Test + func gitTagDeletionKeepsARestorableSessionRecord() async { + var notifications: [String] = [] + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + deleteTagResult: GitProcessResult( + arguments: ["tag", "-d", "v1.0"], + output: "Deleted tag 'v1.0'\n", + exitCode: 0, + tagDeletion: GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .annotated, + message: "release" + ) + ) + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/tags/v1.0", + shortName: "v1.0", + kind: .tag, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteTag(reference) + + #expect(feature.recentlyDeletedTag == GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .annotated, + message: "release" + )) + #expect(notifications == ["Deleted tag v1.0"]) + + feature.dismissDeletedTagBanner() + #expect(feature.recentlyDeletedTag == nil) + } + + @Test + func gitTagDeletionFailureRecordsNothingAndNotifiesTheError() async { + var notifications: [String] = [] + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + deleteTagResult: GitProcessResult( + arguments: ["tag", "-d", "v1.0"], + output: "The tag 'v1.0' does not exist", + exitCode: 1 + ) + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/tags/v1.0", + shortName: "v1.0", + kind: .tag, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteTag(reference) + + #expect(feature.recentlyDeletedTag == nil) + #expect(notifications == ["The tag 'v1.0' does not exist"]) + } + + @Test + func gitTagRestoreReplaysRecordedNameTargetAndMessage() async { + var notifications: [String] = [] + let recorder = TagCallRecorder() + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + createTagResult: GitProcessResult(arguments: ["tag", "-a", "v1.0", "-m", "release", "abc123def456"], output: "", exitCode: 0), + deleteTagResult: GitProcessResult( + arguments: ["tag", "-d", "v1.0"], + output: "Deleted tag 'v1.0'\n", + exitCode: 0, + tagDeletion: GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .annotated, + message: "release" + ) + ), + tagCallRecorder: recorder + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/tags/v1.0", + shortName: "v1.0", + kind: .tag, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteTag(reference) + await feature.restoreRecentlyDeletedTag() + + // Exactly one delete and one restore create must have run, and the + // restore must replay exactly the recorded deletion record so the + // rebuilt annotated tag points at the original commit with its + // message. The delete itself records no revision. + #expect(recorder.recorded.count == 2) + #expect(recorder.recorded.first?.name == "v1.0") + #expect(recorder.recorded.last == TagCallRecorder.Call( + name: "v1.0", + revision: "abc123def456", + message: "release" + )) + #expect(feature.recentlyDeletedTag == nil) + #expect(notifications == ["Deleted tag v1.0", "Restored tag v1.0"]) + } + + @Test + func gitTagRestoreFailureKeepsTheRecordForARetry() async { + var notifications: [String] = [] + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + createTagResult: GitProcessResult( + arguments: ["tag", "v1.0", "abc123def456"], + output: "A tag named 'v1.0' already exists", + exitCode: 1 + ), + deleteTagResult: GitProcessResult( + arguments: ["tag", "-d", "v1.0"], + output: "Deleted tag 'v1.0'\n", + exitCode: 0, + tagDeletion: GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .lightweight, + message: nil + ) + ) + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/tags/v1.0", + shortName: "v1.0", + kind: .tag, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteTag(reference) + await feature.restoreRecentlyDeletedTag() + + // The user can retry after fixing the conflict, or close the banner. + #expect(feature.recentlyDeletedTag?.name == "v1.0") + #expect(notifications == ["Deleted tag v1.0", "A tag named 'v1.0' already exists"]) + } + + @Test + func gitTagRestoreRejectsAnInconsistentRecoveryRecord() async { + var notifications: [String] = [] + let recorder = TagCallRecorder() + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + createTagResult: GitProcessResult(arguments: ["tag", "v1.0"], output: "", exitCode: 0), + deleteTagResult: GitProcessResult( + arguments: ["tag", "-d", "v1.0"], + output: "Deleted tag 'v1.0'\n", + exitCode: 0, + tagDeletion: GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .lightweight, + message: "unexpected annotation" + ) + ), + tagCallRecorder: recorder + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/tags/v1.0", + shortName: "v1.0", + kind: .tag, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteTag(reference) + await feature.restoreRecentlyDeletedTag() + + #expect(feature.recentlyDeletedTag == nil) + #expect(recorder.recorded.count == 1, "invalid recovery data must not issue createTag") + #expect(notifications == ["Deleted tag v1.0", "The deleted tag recovery record is invalid"]) + } + + @Test + func gitFeatureModelResetClearsTheRestorableTagRecord() async { + var notifications: [String] = [] + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + deleteTagResult: GitProcessResult( + arguments: ["tag", "-d", "v1.0"], + output: "Deleted tag 'v1.0'\n", + exitCode: 0, + tagDeletion: GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .lightweight, + message: nil + ) + ) + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/tags/v1.0", + shortName: "v1.0", + kind: .tag, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteTag(reference) + #expect(feature.recentlyDeletedTag != nil) + + // Project close resets the model; the deletion record must not survive + // into the next session. + feature.reset() + #expect(feature.recentlyDeletedTag == nil) + } + + // MARK: Branch deletion restore + + @Test + func gitBranchDeletionKeepsARestorableSessionRecord() async { + var notifications: [String] = [] + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + deleteBranchResult: GitProcessResult( + arguments: ["branch", "-d", "--", "feature/short-lived"], + output: "Deleted branch feature/short-lived\n", + exitCode: 0, + branchDeletion: GitBranchDeletion( + name: "feature/short-lived", + deletedTarget: "abc123def456" + ) + ) + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/heads/feature/short-lived", + shortName: "feature/short-lived", + kind: .local, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteBranch(reference) + + #expect(feature.recentlyDeletedBranch == GitBranchDeletion( + name: "feature/short-lived", + deletedTarget: "abc123def456" + )) + #expect(notifications == ["Deleted branch feature/short-lived"]) + + feature.dismissDeletedBranchBanner() + #expect(feature.recentlyDeletedBranch == nil) + } + + @Test + func gitBranchDeletionFailureClearsThePreviousRecoveryRecord() async { + var notifications: [String] = [] + let results = GitProcessResultQueue([ + GitProcessResult( + arguments: ["branch", "-d", "--", "feature/a"], + output: "Deleted branch feature/a\n", + exitCode: 0, + branchDeletion: GitBranchDeletion(name: "feature/a", deletedTarget: "abc123def456") + ), + GitProcessResult( + arguments: ["branch", "-d", "--", "feature/b"], + output: "The branch 'feature/b' does not exist", + exitCode: 1 + ) + ]) + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + deleteBranchResults: results + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + + await feature.deleteBranch(GitReference( + fullName: "refs/heads/feature/a", + shortName: "feature/a", + kind: .local, + isCurrent: false, + upstreamShortName: nil + )) + #expect(feature.recentlyDeletedBranch?.name == "feature/a") + + await feature.deleteBranch(GitReference( + fullName: "refs/heads/feature/b", + shortName: "feature/b", + kind: .local, + isCurrent: false, + upstreamShortName: nil + )) + + #expect(feature.recentlyDeletedBranch == nil) + #expect(notifications == ["Deleted branch feature/a", "The branch 'feature/b' does not exist"]) + } + + @Test + func gitTagAndBranchRecoveryRecordsCanCoexist() async { + let (feature, _) = makeTagTestFeature(TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + deleteTagResult: GitProcessResult( + arguments: ["tag", "-d", "v1.0"], + output: "Deleted tag 'v1.0'\n", + exitCode: 0, + tagDeletion: GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .lightweight, + message: nil + ) + ), + deleteBranchResult: GitProcessResult( + arguments: ["branch", "-d", "--", "feature/a"], + output: "Deleted branch feature/a\n", + exitCode: 0, + branchDeletion: GitBranchDeletion(name: "feature/a", deletedTarget: "abc123def456") + ) + )) + await feature.refreshGit() + + await feature.deleteTag(GitReference( + fullName: "refs/tags/v1.0", + shortName: "v1.0", + kind: .tag, + isCurrent: false, + upstreamShortName: nil + )) + await feature.deleteBranch(GitReference( + fullName: "refs/heads/feature/a", + shortName: "feature/a", + kind: .local, + isCurrent: false, + upstreamShortName: nil + )) + + #expect(feature.recentlyDeletedTag?.name == "v1.0") + #expect(feature.recentlyDeletedBranch?.name == "feature/a") + } + + @Test + func gitBranchRestoreReplaysRecordedNameAndTarget() async { + var notifications: [String] = [] + let recorder = BranchCallRecorder() + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + createBranchResult: GitProcessResult(arguments: ["branch", "feature/short-lived", "abc123def456"], output: "", exitCode: 0), + deleteBranchResult: GitProcessResult( + arguments: ["branch", "-d", "--", "feature/short-lived"], + output: "Deleted branch feature/short-lived\n", + exitCode: 0, + branchDeletion: GitBranchDeletion( + name: "feature/short-lived", + deletedTarget: "abc123def456" + ) + ), + branchCallRecorder: recorder + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/heads/feature/short-lived", + shortName: "feature/short-lived", + kind: .local, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteBranch(reference) + await feature.restoreRecentlyDeletedBranch() + + // The restore replays createBranch against the recorded commit without + // checking the branch out. + #expect(Array(recorder.recorded.suffix(1)) == [ + BranchCallRecorder.Call(name: "feature/short-lived", reference: "abc123def456", checkout: false) + ]) + #expect(feature.recentlyDeletedBranch == nil) + #expect(notifications == ["Deleted branch feature/short-lived", "Restored branch feature/short-lived"]) + + // Project close drops the restorable record as well. + feature.reset() + #expect(feature.recentlyDeletedBranch == nil) + } + + @Test + func gitServicePreservesExecutedArgumentsAndWorkingDirectory() async { let root = URL(fileURLWithPath: "/workspace") let change = GitChange( repositoryRoot: root, path: "README.md", @@ -1442,6 +1978,72 @@ private func waitForGitWorkToBecomeIdle( return !isActive() } +/// Records tag create/delete arguments so restore flows can be asserted on +/// the exact parameters the feature model replays. +private final class TagCallRecorder: @unchecked Sendable { + struct Call: Equatable { + let name: String + let revision: String + let message: String? + } + + private let lock = NSLock() + private var calls: [Call] = [] + + func record(_ call: Call) { + lock.lock() + calls.append(call) + lock.unlock() + } + + var recorded: [Call] { + lock.lock() + defer { lock.unlock() } + return calls + } +} + +/// Records branch create/delete arguments for the branch restore flow. +private final class BranchCallRecorder: @unchecked Sendable { + struct Call: Equatable { + let name: String + let reference: String + let checkout: Bool + } + + private let lock = NSLock() + private var calls: [Call] = [] + + func record(_ call: Call) { + lock.lock() + calls.append(call) + lock.unlock() + } + + var recorded: [Call] { + lock.lock() + defer { lock.unlock() } + return calls + } +} + +/// Supplies deterministic per-call results for consecutive branch mutations. +private final class GitProcessResultQueue: @unchecked Sendable { + private let lock = NSLock() + private var results: [GitProcessResult] + + init(_ results: [GitProcessResult]) { + self.results = results + } + + func next() -> GitProcessResult? { + lock.lock() + defer { lock.unlock() } + guard !results.isEmpty else { return nil } + return results.removeFirst() + } +} + private struct TestGitOperations: GitOperations { private let snapshotValue: GitSnapshot? private let comparisonValue: GitBranchComparison? @@ -1453,6 +2055,13 @@ private struct TestGitOperations: GitOperations { private let runGate: TestGitRunGate? private let filesRecorder: GitFilesCallRecorder? private let filesGate: GitFilesLoadGate? + private let createTagResult: GitProcessResult? + private let deleteTagResult: GitProcessResult? + private let tagCallRecorder: TagCallRecorder? + private let createBranchResult: GitProcessResult? + private let deleteBranchResult: GitProcessResult? + private let deleteBranchResults: GitProcessResultQueue? + private let branchCallRecorder: BranchCallRecorder? init( snapshotValue: GitSnapshot? = nil, @@ -1464,7 +2073,14 @@ private struct TestGitOperations: GitOperations { stageResult: GitProcessResult? = nil, runGate: TestGitRunGate? = nil, filesRecorder: GitFilesCallRecorder? = nil, - filesGate: GitFilesLoadGate? = nil + filesGate: GitFilesLoadGate? = nil, + createTagResult: GitProcessResult? = nil, + deleteTagResult: GitProcessResult? = nil, + tagCallRecorder: TagCallRecorder? = nil, + createBranchResult: GitProcessResult? = nil, + deleteBranchResult: GitProcessResult? = nil, + deleteBranchResults: GitProcessResultQueue? = nil, + branchCallRecorder: BranchCallRecorder? = nil ) { self.snapshotValue = snapshotValue self.comparisonValue = comparisonValue @@ -1476,6 +2092,13 @@ private struct TestGitOperations: GitOperations { self.runGate = runGate self.filesRecorder = filesRecorder self.filesGate = filesGate + self.createTagResult = createTagResult + self.deleteTagResult = deleteTagResult + self.tagCallRecorder = tagCallRecorder + self.createBranchResult = createBranchResult + self.deleteBranchResult = deleteBranchResult + self.deleteBranchResults = deleteBranchResults + self.branchCallRecorder = branchCallRecorder } func run(arguments: [String], workingDirectory: String, input: String?) -> GitProcessResult { @@ -1518,9 +2141,15 @@ private struct TestGitOperations: GitOperations { func cherryPick(_ hash: String, at rootURL: URL) -> GitProcessResult? { nil } func revert(_ hash: String, at rootURL: URL) -> GitProcessResult? { nil } func resetCurrentBranch(to hash: String, mode: String, at rootURL: URL) -> GitProcessResult? { nil } - func createBranch(named name: String, from reference: GitReference, checkout: Bool, at rootURL: URL) -> GitProcessResult? { nil } + func createBranch(named name: String, from reference: GitReference, checkout: Bool, at rootURL: URL) -> GitProcessResult? { + branchCallRecorder?.record(BranchCallRecorder.Call(name: name, reference: reference.fullName, checkout: checkout)) + return createBranchResult + } func renameBranch(_ reference: GitReference, to name: String, at rootURL: URL) -> GitProcessResult? { nil } - func deleteBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } + func deleteBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { + branchCallRecorder?.record(BranchCallRecorder.Call(name: reference.shortName, reference: reference.fullName, checkout: false)) + return deleteBranchResults?.next() ?? deleteBranchResult + } func mergeBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } func rebaseCurrentBranch(onto reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } func checkoutAndRebase(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { @@ -1560,4 +2189,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 8132218d..8b8db818 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -119,6 +119,14 @@ pub struct GitCommandResponse { /// output. #[serde(skip_serializing_if = "Option::is_none")] pub stash_restore: Option, + /// Present when a tag deletion succeeded, carrying everything a host needs + /// to offer a restore without re-querying the repository. + #[serde(skip_serializing_if = "Option::is_none")] + pub tag_deletion: Option, + /// Present when a local branch deletion succeeded, carrying the commit the + /// branch pointed at so the host can offer to recreate it. + #[serde(skip_serializing_if = "Option::is_none")] + pub branch_deletion: Option, } /// Raw Git process streams kept separate for machine-readable consumers. @@ -148,6 +156,8 @@ impl GitProcessOutput { invocations: vec![invocation], operation_error: None, stash_restore: None, + tag_deletion: None, + branch_deletion: None, } } } @@ -216,6 +226,34 @@ pub struct GitStashRestoreResponse { pub conflicted_paths: Vec, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Deletion record that lets a host rebuild the deleted tag later. +/// +/// `deleted_target` is the commit the deleted ref resolved to (peeled for +/// annotated tags), so a restore can re-point a new tag at the same commit. +pub struct GitTagDeletionResponse { + /// Short name of the deleted tag, without the `refs/tags/` prefix. + pub name: String, + pub deleted_target: String, + /// `lightweight` or `annotated`, taken from the tag object type. + pub kind: String, + /// Annotation message; `None` only for lightweight tags. Empty annotated + /// messages remain `Some` so a restore does not change the tag form. + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Deletion record that lets a host recreate the deleted local branch later. +pub struct GitBranchDeletionResponse { + /// Short branch name, without the `refs/heads/` prefix. + pub name: String, + /// Commit the deleted branch pointed at when it was removed. + pub deleted_target: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] /// Typed mutation request translated into a controlled Git invocation. @@ -536,7 +574,7 @@ fn write_with_trace(request: GitWriteRequest) -> Result { let reference = validated_reference(request.reference.as_deref())?; @@ -596,6 +634,44 @@ fn write_with_trace(request: GitWriteRequest) -> Result { + let name = validated_tag_name(request.name.as_deref())?; + let requested_target = validated_revision(request.revision.as_deref())?; + // Existence and resolvability probes run before Git so a duplicate + // or unresolvable target fails with a stable message instead of + // leaving the caller to parse localized `git tag` stderr. + if tag_exists(&root, &name)? { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("A tag named '{name}' already exists"), + )); + } + // Git can tag trees and blobs, but the deletion/restore contract + // promises a commit target, so anything else is rejected here and + // the tag is created against the resolved commit id. + let Some(target) = resolved_commit_target(&root, &requested_target)? else { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("Could not resolve tag target '{requested_target}'"), + )); + }; + // An explicit message field (even an empty one) selects the + // annotated form. Verbatim cleanup keeps restored CRLF and trailing + // blank lines intact; UIs trim newly entered messages before send. + arguments = match request.message.as_deref() { + Some(message) => vec![ + "tag".into(), + "-a".into(), + "--cleanup=verbatim".into(), + name, + "-m".into(), + message.to_string(), + target, + ], + None => vec!["tag".into(), name, target], + }; + } + "deleteTag" => return delete_tag(&root, request.name.as_deref()), "clone" => { let remote = required_text(request.remote.as_deref(), "clone source")?; let destination = required_text(request.destination.as_deref(), "clone destination")?; @@ -1953,6 +2029,228 @@ fn validated_branch_name(root: &str, value: Option<&str>) -> Result) -> Result { + let value = required_text(value, "tag name")?; + if is_invalid_tag_name(&value) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git tag name", + )); + } + Ok(value) +} + +/// Refname rules from `git check-ref-format` plus command-line safety guards +/// (no leading dash) shared by every Git mutation argument. +fn is_invalid_tag_name(value: &str) -> bool { + if value.starts_with('-') + || value == "@" + || value.starts_with('/') + || value.ends_with('/') + || value.ends_with('.') + || value.contains("..") + || value.contains("@{") + || value.contains("//") + { + return true; + } + if value.chars().any(|character| { + character.is_control() + || matches!(character, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\') + }) { + return true; + } + value + .split('/') + .any(|component| component.starts_with('.') || component.ends_with(".lock")) +} + +/// Reports whether `refs/tags/` already resolves, using `--verify` so +/// the probe matches the exact ref instead of any revision expression. +fn tag_exists(root: &str, name: &str) -> Result { + let probe = execute_git( + root, + &[ + "rev-parse".into(), + "--verify".into(), + "--quiet".into(), + format!("refs/tags/{name}"), + ], + None, + )?; + Ok(probe.exit_code == 0) +} + +/// Resolves a tag target revision to a commit and returns its object id. +/// Git allows tagging trees and blobs; the tag contract only promises commit +/// targets, so `^{commit}` both validates and yields the id the +/// mutation should point at. +fn resolved_commit_target(root: &str, target: &str) -> Result, CoreError> { + let probe = execute_git( + root, + &[ + "rev-parse".into(), + "--verify".into(), + "--quiet".into(), + format!("{target}^{{commit}}"), + ], + None, + )?; + Ok((probe.exit_code == 0).then(|| probe.stdout.trim().to_string())) +} + +/// Deletes one tag and returns a structured deletion record so the host can +/// offer a restore. The probes run before the deletion because `git tag -d` +/// diagnostics are localized prose that cannot be mapped to stable errors. +fn delete_tag(root: &str, value: Option<&str>) -> Result { + let name = validated_tag_name(value)?; + let reference = format!("refs/tags/{name}"); + let 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 to a commit so pre-existing tree/blob tags cannot produce + // a recovery record that violates the restore contract. + let peeled = execute_git( + root, + &[ + "rev-parse".into(), + "--verify".into(), + format!("{reference}^{{commit}}"), + ], + None, + )?; + if peeled.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + format!("Could not resolve tag target '{name}'"), + ) + .with_details(peeled.output)); + } + let mut result = execute_git(root, &["tag".into(), "-d".into(), name.clone()], None)?; + if result.exit_code == 0 && !ref_exists(&root, &reference)? { + // Defense against the probe-mutate race: the record is only offered + // when the ref is actually gone, never against re-created state. + result.tag_deletion = Some(GitTagDeletionResponse { + name, + deleted_target: peeled.stdout.trim().to_string(), + kind: if is_annotated { + "annotated" + } else { + "lightweight" + } + .to_string(), + message, + }); + } + Ok(result) +} + +/// Deletes one local branch and returns a structured deletion record so the +/// host can offer a restore. The commit is resolved before the deletion +/// because `git branch -d` diagnostics are localized prose. +fn delete_branch(root: &str, branch: &str) -> Result { + let 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 && !ref_exists(&root, &format!("refs/heads/{branch}"))? { + // Same probe-mutate defense as tags: only offer a restore when the + // ref is actually gone. + result.branch_deletion = Some(GitBranchDeletionResponse { + name: branch.to_string(), + deleted_target: target.stdout.trim().to_string(), + }); + } + Ok(result) +} + +/// Reports whether a full refname currently resolves, used after a deletion +/// to confirm the ref is really gone before offering a restore. +fn ref_exists(root: &str, reference: &str) -> 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 mut offset = 0; + for line in message.split_inclusive('\n') { + let without_eol = line.trim_end_matches(['\r', '\n']); + if without_eol.starts_with("-----BEGIN ") && without_eol.ends_with("SIGNATURE-----") { + return Some(message[..offset].to_string()); + } + offset += line.len(); + } + Some(message.to_string()) +} + fn local_branch_name(reference: &str) -> Result { let branch = reference .strip_prefix("refs/heads/") @@ -2021,6 +2319,8 @@ fn failed_git_result(error: CoreError) -> GitCommandResponse { invocations: Vec::new(), operation_error: Some(error), stash_restore: None, + tag_deletion: None, + branch_deletion: None, } } @@ -3155,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() { @@ -3214,6 +3556,8 @@ mod tests { ], operation_error: None, stash_restore: None, + tag_deletion: None, + branch_deletion: None, }; super::synchronize_final_invocation(&mut response); @@ -3392,6 +3736,8 @@ mod tests { ], operation_error: None, stash_restore: None, + tag_deletion: None, + branch_deletion: None, }; assert_eq!( @@ -3481,6 +3827,8 @@ mod tests { "Invalid Git reference", )), 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 e20d535f..d3ca9b00 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -613,6 +613,360 @@ 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 CRLF and trailing blank lines + // must survive the later delete/restore round trip byte for byte. + let annotated = request( + "createTag", + serde_json::json!({ + "name": "v2.0", + "revision": "HEAD", + "message": "release\r\n\r\nsecond paragraph\r\n\r\n" + }), + ); + assert_eq!(annotated["ok"], true, "{annotated:?}"); + assert_eq!( + String::from_utf8_lossy(&run(&["cat-file", "-t", "refs/tags/v2.0"]).stdout).trim(), + "tag" + ); + + // An empty name is a missing required field. The format matrix below is + // shared with the macOS dialog through `tag-names.json`, so both sides + // reject exactly the same names: `git check-ref-format` refname rules, + // per-component checks such as `foo/.bar`, and the command-line guards. + // Each rejection must happen before any subprocess so the error stays a + // plain invalid_request envelope. + let tag_names: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/git/tag-names.json" + ))) + .expect("tag name fixture should be valid JSON"); + let empty = request( + "createTag", + serde_json::json!({"name": "", "revision": "HEAD"}), + ); + assert_eq!(empty["ok"], false, "{empty:?}"); + assert_eq!(empty["error"]["code"], "invalid_request"); + assert_eq!(empty["error"]["message"], "Missing or invalid Git tag name"); + for name in tag_names["invalid"].as_array().expect("invalid list") { + let name = name.as_str().expect("invalid name should be a string"); + let response = request( + "createTag", + serde_json::json!({"name": name, "revision": "HEAD"}), + ); + assert_eq!(response["ok"], false, "name {name:?}: {response:?}"); + assert_eq!( + response["error"]["code"], "invalid_request", + "name {name:?}" + ); + } + for name in tag_names["valid"].as_array().expect("valid list") { + let name = name.as_str().expect("valid name should be a string"); + let response = request( + "createTag", + serde_json::json!({"name": name, "revision": "HEAD"}), + ); + assert_eq!(response["ok"], true, "name {name:?}: {response:?}"); + } + + // A duplicate name is a structured operation error so callers can show it + // in place instead of parsing Git's stderr. + let duplicate = request( + "createTag", + serde_json::json!({"name": "v1.0", "revision": "HEAD"}), + ); + assert_eq!(duplicate["ok"], true, "{duplicate:?}"); + assert_eq!( + duplicate["data"]["operationError"]["code"], + "invalid_request" + ); + assert_eq!( + duplicate["data"]["operationError"]["message"], + "A tag named 'v1.0' already exists" + ); + + let unresolvable = request( + "createTag", + serde_json::json!({"name": "v3.0", "revision": "no-such-revision"}), + ); + assert_eq!(unresolvable["ok"], true, "{unresolvable:?}"); + assert_eq!( + unresolvable["data"]["operationError"]["code"], + "invalid_request" + ); + assert_eq!( + unresolvable["data"]["operationError"]["message"], + "Could not resolve tag target 'no-such-revision'" + ); + + // Tree and blob revisions are valid Git objects but not valid targets for + // this commit-only contract. Neither rejection may leave a tag behind. + let tree = String::from_utf8_lossy(&run(&["rev-parse", "HEAD^{tree}"]).stdout) + .trim() + .to_string(); + let blob = String::from_utf8_lossy(&run(&["hash-object", "example.txt"]).stdout) + .trim() + .to_string(); + for (name, revision) in [("tree-target", tree), ("blob-target", blob)] { + let response = request( + "createTag", + serde_json::json!({"name": name, "revision": revision}), + ); + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!( + response["data"]["operationError"]["message"], + format!("Could not resolve tag target '{revision}'") + ); + assert_ne!( + run(&[ + "rev-parse", + "--verify", + "--quiet", + &format!("refs/tags/{name}") + ]) + .status + .code(), + Some(0) + ); + } + + // Deleting a lightweight tag reports the commit it pointed at. + let delete_lightweight = request("deleteTag", serde_json::json!({"name": "v1.0"})); + assert_eq!(delete_lightweight["ok"], true, "{delete_lightweight:?}"); + assert_eq!(delete_lightweight["data"]["exitCode"], 0); + assert_eq!(delete_lightweight["data"]["tagDeletion"]["name"], "v1.0"); + assert_eq!( + delete_lightweight["data"]["tagDeletion"]["kind"], + "lightweight" + ); + assert_eq!( + delete_lightweight["data"]["tagDeletion"]["deletedTarget"], + head + ); + assert!(delete_lightweight["data"]["tagDeletion"] + .get("message") + .is_none()); + assert!( + run(&["rev-parse", "--verify", "--quiet", "refs/tags/v1.0"]) + .status + .code() + != Some(0) + ); + + // Deleting an annotated tag carries the peeled commit and the original + // annotation so a restore can rebuild the message byte for byte. + let delete_annotated = request("deleteTag", serde_json::json!({"name": "v2.0"})); + assert_eq!(delete_annotated["ok"], true, "{delete_annotated:?}"); + let deletion = &delete_annotated["data"]["tagDeletion"]; + assert_eq!(deletion["name"], "v2.0"); + assert_eq!(deletion["kind"], "annotated"); + assert_eq!( + deletion["message"], + "release\r\n\r\nsecond paragraph\r\n\r\n" + ); + assert_eq!(deletion["deletedTarget"], head); + + // Deleting a missing tag fails with the stable not-exist message. + let missing = request("deleteTag", serde_json::json!({"name": "v1.0"})); + assert_eq!(missing["ok"], true, "{missing:?}"); + assert_eq!(missing["data"]["operationError"]["code"], "invalid_request"); + assert_eq!( + missing["data"]["operationError"]["message"], + "The tag 'v1.0' does not exist" + ); + + // Restoring reuses createTag with the recorded target and message, so the + // rebuilt annotation must match the original message exactly. + let restore = request( + "createTag", + serde_json::json!({ + "name": deletion["name"], + "revision": deletion["deletedTarget"], + "message": deletion["message"] + }), + ); + assert_eq!(restore["ok"], true, "{restore:?}"); + let restored_object = run(&["cat-file", "tag", "refs/tags/v2.0"]); + assert!(restored_object + .stdout + .ends_with(b"\n\nrelease\r\n\r\nsecond paragraph\r\n\r\n")); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_write_records_the_deleted_branch_target() { + let root = temporary_root("git-branch-delete"); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q", "-b", "main"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + fs::write(root.join("example.txt"), "initial\n").expect("file should be writable"); + assert!(run(&["add", "example.txt"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + assert!(run(&["branch", "feature/short-lived"]).status.success()); + 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 37b32f5e..9d25a70c 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -227,10 +227,10 @@ response retains the invocation trace and includes the failure as `reset`, `createBranch`, `publishBranch`, `renameBranch`, `deleteBranch`, `merge`, `rebase`, `checkoutAndRebase`, `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. @@ -269,6 +269,34 @@ upstream. When `reference` is present, it must be a complete 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`: when the field is present (including an +empty value), it creates an annotated tag (`git tag -a`); an absent field +creates a lightweight tag. UI callers trim new user-entered messages. Core +passes the supplied annotation with verbatim cleanup so restore preserves +CRLF and trailing blank lines, and an explicit empty value preserves an empty +annotated tag. Tag +names must satisfy the `git check-ref-format` refname rules and must not +begin with a dash; `shared/fixtures/git/tag-names.json` pins the boundary cases +for Core and host-side validation. Before invoking Git, `createTag` probes the +repository so +a duplicate tag (`A tag named '' already exists`) and an unresolvable +non-commit target (`Could not resolve tag target ''`) fail with stable +`invalid_request` messages instead of localized Git output. `deleteTag` uses +`name` and removes `refs/tags/`; a missing tag fails with +`The tag '' does not exist`. On success the response carries a +structured `tagDeletion` record — `{ "name": string, "deletedTarget": string, +"kind": "lightweight" | "annotated", "message": string? }` — where +`deletedTarget` is the peeled commit the deleted ref resolved to and +`message` is the original annotation with its line breaks preserved. Hosts +can rebuild the tag by replaying `createTag` with `name`, `deletedTarget`, +and `message`; the tagger identity and timestamp are intentionally not +preserved. `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 an operation kind from the caller. Continue is rejected while conflicted paths diff --git a/shared/fixtures/git/tag-names.json b/shared/fixtures/git/tag-names.json new file mode 100644 index 00000000..2aa2589d --- /dev/null +++ b/shared/fixtures/git/tag-names.json @@ -0,0 +1,35 @@ +{ + "protocolVersion": 1, + "valid": [ + "v1", + "v1.0.0", + "release-2026.08", + "feature/x", + "a/b/c", + "v1_rc1" + ], + "invalid": [ + "", + " ", + "a..b", + "-v1", + "a b", + "v~1", + "a:b", + "a?b", + "a*b", + "a[b", + "a\\b", + "@{x", + "a//b", + "a.lock", + ".hidden", + "@", + "head/", + "x/../y", + "foo/.bar", + "foo/bar.lock", + "a/.b/c", + "x/./y" + ] +} diff --git a/shared/fixtures/git/write.json b/shared/fixtures/git/write.json index 1740d6ba..0bcf62ea 100644 --- a/shared/fixtures/git/write.json +++ b/shared/fixtures/git/write.json @@ -50,6 +50,51 @@ "message": "Save work", "includeUntracked": true } + }, + { + "operation": "createTag", + "payload": { + "name": "v1.0.0", + "revision": "2f1c9a4" + } + }, + { + "operation": "createTag", + "payload": { + "name": "v2.0.0", + "revision": "8e4b2d7", + "message": "Release 2.0.0" + } + }, + { + "operation": "deleteTag", + "payload": { + "name": "v2.0.0" + } + }, + { + "operation": "deleteBranch", + "payload": { + "reference": "refs/heads/feature/pending" + } + } + ], + "responses": [ + { + "operation": "deleteTag", + "tagDeletion": { + "name": "v2.0.0", + "deletedTarget": "8e4b2d7bb019f0e5a3c6f1a2d4e5b6c7d8e9f0a1", + "kind": "annotated", + "message": "Release 2.0.0" + } + }, + { + "operation": "deleteBranch", + "branchDeletion": { + "name": "feature/pending", + "deletedTarget": "2f1c9a4bb019f0e5a3c6f1a2d4e5b6c7d8e9f0a1" + } } ], "invalidRequests": [ @@ -68,6 +113,14 @@ }, "errorCode": "invalid_request" }, + { + "operation": "createTag", + "payload": { + "name": "bad tag name", + "revision": "HEAD" + }, + "errorCode": "invalid_request" + }, { "operation": "pull", "payload": {