├── Screenshots ├── Screenshot2-1.png └── Screenshot2-2.png ├── GitClient ├── Assets.xcassets │ ├── Contents.json │ ├── ruby.imageset │ │ ├── ruby.png │ │ └── Contents.json │ ├── rust.imageset │ │ ├── rust-lang-logo-black 1.png │ │ ├── rust-lang-logo-black.png │ │ ├── rust-lang-logo-white.png │ │ └── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── js.imageset │ │ ├── Contents.json │ │ └── js.svg │ ├── Swift.imageset │ │ ├── Contents.json │ │ └── Swift_logo_color.svg │ ├── python.imageset │ │ └── Contents.json │ ├── ocaml.imageset │ │ └── Contents.json │ └── AppIcon.appiconset │ │ └── Contents.json ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── GitClient.entitlements ├── Models │ ├── WindowID.swift │ ├── CommitDetail.swift │ ├── AppStorageKey.swift │ ├── SearchToken.swift │ ├── AppStorageDefaults.swift │ ├── Folder.swift │ ├── GenericError.swift │ ├── Commands │ │ ├── GitFetch.swift │ │ ├── Git.swift │ │ ├── GitAdd.swift │ │ ├── GitDiffCached.swift │ │ ├── GitMerge.swift │ │ ├── GitRestore.swift │ │ ├── GitSwitch.swift │ │ ├── GitAddPatch.swift │ │ ├── GitAddPathspec.swift │ │ ├── GitCheckout.swift │ │ ├── GitCommit.swift │ │ ├── GitPull.swift │ │ ├── GitPush.swift │ │ ├── GitStash │ │ │ ├── GitStashApply.swift │ │ │ ├── GitStashDrop.swift │ │ │ ├── GitStashShowDiff.swift │ │ │ ├── GitStash.swift │ │ │ └── GitStashList.swift │ │ ├── GitSwitchDetach.swift │ │ ├── GitTagDelete.swift │ │ ├── GitTag.swift │ │ ├── GitRestorePatch.swift │ │ ├── GitCommitAmend.swift │ │ ├── GitTagCreate.swift │ │ ├── GitCheckoutB.swift │ │ ├── GitTagPointsAt.swift │ │ ├── GitBranchRename.swift │ │ ├── GitBranchDelete.swift │ │ ├── GitRevListCount.swift │ │ ├── GitDiffShortStat.swift │ │ ├── GitRevParse.swift │ │ ├── GitShowShortstat.swift │ │ ├── GitShowref.swift │ │ ├── GitBranchPointsAt.swift │ │ ├── GitBranch.swift │ │ ├── GitRevert.swift │ │ ├── GitStatusShort.swift │ │ ├── GitDiff.swift │ │ ├── GitDiffNumStat.swift │ │ ├── GitShow.swift │ │ └── GitLog.swift │ ├── SearchArguments.swift │ ├── FolderViewShowing.swift │ ├── GitFetchExecutor.swift │ ├── Stash.swift │ ├── DiffStat.swift │ ├── Status.swift │ ├── DefaultMergeCommitMessage.swift │ ├── Log.swift │ ├── Branch.swift │ ├── ExpandableModel.swift │ ├── Observables │ │ └── SyncState.swift │ ├── Commit.swift │ ├── Language.swift │ ├── SearchTokensHandler.swift │ ├── SearchKind.swift │ └── CommitGraph.swift ├── Extensions │ ├── Notification+.swift │ ├── Collection+.swift │ ├── String+.swift │ ├── URL+.swift │ └── Process+Run.swift ├── Views │ ├── Extensions │ │ ├── EnviromentValues+.swift │ │ └── View+.swift │ ├── StashChanged │ │ ├── StashChangedDetailContentView.swift │ │ └── StashChangedView.swift │ ├── Commit │ │ ├── DiffTabView.swift │ │ ├── SectionHeader.swift │ │ ├── StageFileDiffHeaderView.swift │ │ ├── BarBackButton.swift │ │ ├── DiffView.swift │ │ ├── CommitDetailStackView.swift │ │ ├── CommitDetailView.swift │ │ ├── FileDiffView.swift │ │ ├── FileDiffsView.swift │ │ ├── CommitMessageGenerationView.swift │ │ ├── StagedView.swift │ │ ├── StageFileDiffsView.swift │ │ ├── CommitMessageGenerationUnavailableView.swift │ │ ├── CommitMessageSnippetSuggestionView.swift │ │ ├── DiffCommitListContentView.swift │ │ ├── CommitDetailBottomBar.swift │ │ ├── MergeCommitContentView.swift │ │ ├── FileNameView.swift │ │ ├── ChunkView.swift │ │ ├── DiffCommitListView.swift │ │ ├── FileDiffTheme.swift │ │ ├── StageFileDiffView.swift │ │ ├── CommitDetailHeaderView.swift │ │ ├── CommitMessageSnippetView.swift │ │ ├── CommitMessageGenerationContentView.swift │ │ ├── CommitMessageEditor.swift │ │ └── UnstagedView.swift │ ├── Tags │ │ ├── TagsView.swift │ │ └── TagsContentView.swift │ ├── Folder │ │ ├── CommitRowView.swift │ │ ├── AuthorInitialIcon.swift │ │ ├── Icon.swift │ │ ├── Sheets │ │ │ ├── AmendCommitSheet.swift │ │ │ ├── RenameBranchSheet.swift │ │ │ ├── CreateNewBranchSheet.swift │ │ │ └── CreateNewTagSheet.swift │ │ └── CommitLogView.swift │ ├── ErrorTextSheet.swift │ └── DiffSummaryView.swift ├── AIService │ ├── CommitMessageProperties.swift │ └── StagingChangesProperties.swift ├── AppIcon.icon │ ├── Assets │ │ ├── Oval2.svg │ │ └── Oval.svg │ └── icon.json └── GitClientApp.swift ├── .github ├── ISSUE_TEMPLATE │ ├── custom-issue.md │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── test.yml │ ├── release.yml │ └── codeql.yml ├── GitClient.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── WorkspaceSettings.xcsettings │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved ├── xcuserdata │ └── aoyama.xcuserdatad │ │ └── xcschemes │ │ └── xcschememanagement.plist └── xcshareddata │ └── xcschemes │ └── GitClient.xcscheme ├── .gitmodules ├── ExportOptions.plist ├── GitClientTests ├── GitRevParseTests.swift ├── GitShowShortstatTests.swift ├── BranchTests.swift ├── GitStatusTests.swift ├── GitDiffNumStatTests.swift ├── DefaultMergeCommitMessageTests.swift ├── GitLogTests.swift ├── ExpandableModelTests.swift ├── SyncStateTests.swift └── LogStoreTests.swift ├── GitClientUITests ├── GitClientUITestsLaunchTests.swift └── GitClientUITests.swift ├── LICENSE ├── GitClient.xctestplan ├── README.md └── .gitignore /Screenshots/Screenshot2-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maoyama/Changes/HEAD/Screenshots/Screenshot2-1.png -------------------------------------------------------------------------------- /Screenshots/Screenshot2-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maoyama/Changes/HEAD/Screenshots/Screenshot2-2.png -------------------------------------------------------------------------------- /GitClient/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /GitClient/Assets.xcassets/ruby.imageset/ruby.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maoyama/Changes/HEAD/GitClient/Assets.xcassets/ruby.imageset/ruby.png -------------------------------------------------------------------------------- /GitClient/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom-issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue 3 | about: Make your own issue 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /GitClient/Assets.xcassets/rust.imageset/rust-lang-logo-black 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maoyama/Changes/HEAD/GitClient/Assets.xcassets/rust.imageset/rust-lang-logo-black 1.png -------------------------------------------------------------------------------- /GitClient/Assets.xcassets/rust.imageset/rust-lang-logo-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maoyama/Changes/HEAD/GitClient/Assets.xcassets/rust.imageset/rust-lang-logo-black.png -------------------------------------------------------------------------------- /GitClient/Assets.xcassets/rust.imageset/rust-lang-logo-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maoyama/Changes/HEAD/GitClient/Assets.xcassets/rust.imageset/rust-lang-logo-white.png -------------------------------------------------------------------------------- /GitClient.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /GitClient/GitClient.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /GitClient/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /GitClient/Models/WindowID.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WindowID.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/08/11. 6 | // 7 | 8 | import Foundation 9 | 10 | enum WindowID: String { 11 | case commitMessageSnippets 12 | } 13 | -------------------------------------------------------------------------------- /GitClient/Assets.xcassets/ruby.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "ruby.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /GitClient.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "TestFixtures/SyntaxHighlight"] 2 | path = TestFixtures/SyntaxHighlight 3 | url = git@github.com:maoyama/SyntaxHighlight.git 4 | [submodule "TestFixtures/SyntaxHighlight2"] 5 | path = TestFixtures/SyntaxHighlight2 6 | url = git@github.com:maoyama/SyntaxHighlight.git 7 | -------------------------------------------------------------------------------- /GitClient/Models/CommitDetail.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommitDetail.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/10/08. 6 | // 7 | 8 | import Foundation 9 | import CryptoKit 10 | 11 | struct CommitDetail: Hashable { 12 | var commit: Commit 13 | var diff: Diff 14 | } 15 | -------------------------------------------------------------------------------- /GitClient/Models/AppStorageKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppStorageKey.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/08/11. 6 | // 7 | 8 | import Foundation 9 | 10 | enum AppStorageKey: String { 11 | case folder 12 | case commitMessageSnippet 13 | case searchTokenHisrtory 14 | } 15 | 16 | -------------------------------------------------------------------------------- /GitClient/Models/SearchToken.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchToken.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/04/02. 6 | // 7 | 8 | import Foundation 9 | 10 | struct SearchToken: Identifiable, Hashable, Codable { 11 | var id: Self { self } 12 | var kind: SearchKind 13 | var text: String 14 | } 15 | -------------------------------------------------------------------------------- /GitClient.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /GitClient/Assets.xcassets/js.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "js.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "preserves-vector-representation" : true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /GitClient/Assets.xcassets/Swift.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Swift_logo_color.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "preserves-vector-representation" : true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /GitClient/Assets.xcassets/python.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "python-logo-only.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "preserves-vector-representation" : true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /GitClient/Extensions/Notification+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Notification+.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/08/11. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Notification.Name { 11 | static let didSelectCommitMessageSnippetNotification = Notification.Name("didSelectCommitMessageSnippetNotification") 12 | } 13 | -------------------------------------------------------------------------------- /GitClient/Models/AppStorageDefaults.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppStorageDefaults.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/08/11. 6 | // 7 | 8 | import Foundation 9 | 10 | struct AppStorageDefaults { 11 | static let commitMessageSnippets = try! JSONEncoder().encode(["Tweaks", "Remove unused code", "Fix lint warnings"]) 12 | } 13 | -------------------------------------------------------------------------------- /GitClient/Assets.xcassets/ocaml.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "colour-transparent-icon.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "preserves-vector-representation" : true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /GitClient/Extensions/Collection+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Collection+.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/05/11. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension Collection { 11 | subscript(safe index: Index) -> Element? { 12 | startIndex <= index && index < endIndex ? self[index] : nil 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /GitClient/Models/Folder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Folders.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2022/09/18. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Folder: Hashable, Codable { 11 | var url: URL 12 | var displayName: String { 13 | url.path.components(separatedBy: "/").filter{ !$0.isEmpty }.last ?? "" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /GitClient/Models/GenericError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GenericError.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2022/09/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GenericError: Error, LocalizedError { 11 | var errorDescription: String? 12 | 13 | init(errorDescription: String) { 14 | self.errorDescription = errorDescription 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitFetch.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitFetch.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/08/18. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitFetch: Git { 11 | typealias OutputModel = Void 12 | var arguments = [ 13 | "git", 14 | "fetch", 15 | ] 16 | var directory: URL 17 | 18 | func parse(for stdOut: String) -> Void {} 19 | } 20 | -------------------------------------------------------------------------------- /ExportOptions.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | method 6 | developer-id 7 | teamID 8 | JRA6VW2DG4 9 | signingStyle 10 | automatic 11 | 12 | 13 | -------------------------------------------------------------------------------- /GitClient/Models/SearchArguments.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchArguments.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/06/23. 6 | // 7 | 8 | import Foundation 9 | 10 | struct SearchArguments { 11 | var revisionRange:[String] = [] 12 | var grep: [String] = [] 13 | var grepAllMatch = false 14 | var s = "" 15 | var g = "" 16 | var authors:[String] = [] 17 | var paths: [String] = [] 18 | } 19 | -------------------------------------------------------------------------------- /GitClient/Views/Extensions/EnviromentValues+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EnviromentValues+.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/29. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension EnvironmentValues { 11 | @Entry var folder: URL? 12 | @Entry var expandAllFiles: UUID? 13 | @Entry var collapseAllFiles: UUID? 14 | @Entry var systemLanguageModelAvailability = SystemLanguageModelService().availability 15 | } 16 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/Git.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Git.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2022/09/25. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol Git { 11 | associatedtype OutputModel 12 | var arguments: [String] { get } 13 | var directory: URL { get set } 14 | func parse(for standardOutput: String) throws -> OutputModel 15 | } 16 | 17 | protocol InteractiveGit: Git { 18 | var inputs: [String] { get } 19 | } 20 | -------------------------------------------------------------------------------- /GitClient/Models/FolderViewShowing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FolderViewShowing.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/04/23. 6 | // 7 | 8 | import Foundation 9 | 10 | struct FolderViewShowing { 11 | var branches = false 12 | var createNewBranchFrom: Branch? 13 | var renameBranch: Branch? 14 | var stashChanged = false 15 | var tags = false 16 | var createNewTagAt: Commit? 17 | var amendCommitAt: Commit? 18 | } 19 | 20 | -------------------------------------------------------------------------------- /GitClient/AIService/CommitMessageProperties.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommitMessageProperties.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/29. 6 | // 7 | 8 | import Foundation 9 | 10 | struct CommitMessageProperties: Codable { 11 | struct CommitMessage: Codable { 12 | var type = "string" 13 | } 14 | var commitMessage = CommitMessage() 15 | } 16 | 17 | struct GeneratedCommiMessage: Codable { 18 | var commitMessage: String 19 | } 20 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitAdd.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitAdd.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2022/10/01. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitAdd: Git { 11 | typealias OutputModel = Void 12 | var arguments: [String] { 13 | [ 14 | "git", 15 | "add", 16 | ".", 17 | ] 18 | } 19 | var directory: URL 20 | 21 | func parse(for stdOut: String) -> Void {} 22 | } 23 | -------------------------------------------------------------------------------- /GitClient/AppIcon.icon/Assets/Oval2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Oval2 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /GitClient/Models/GitFetchExecutor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitFetchExecutor.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/05/27. 6 | // 7 | 8 | import Foundation 9 | 10 | actor GitFetchExecutor { 11 | static let shared = GitFetchExecutor() 12 | 13 | func execute(_ command: GitFetch) throws { 14 | try Process.outputSync(command) 15 | } 16 | 17 | func execute(_ command: GitPull) throws { 18 | try Process.outputSync(command) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /GitClient/Extensions/String+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2022/09/26. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | static let formatSeparator = "{separator-44cd166895ac93832525}" 12 | static let componentSeparator = "{component-separator-44cd166895ac93832525}" 13 | 14 | var initial: String { 15 | self.components(separatedBy: .whitespaces).map { $0.prefix(1).uppercased() }.joined() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitDiffCached.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitDiffCached.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2022/09/26. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitDiffCached: Git { 11 | typealias OutputModel = String 12 | var arguments = [ 13 | "git", 14 | "diff", 15 | "--cached", 16 | "--no-renames", 17 | ] 18 | var directory: URL 19 | 20 | func parse(for stdOut: String) -> String { 21 | stdOut 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /GitClient.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "a75221a525042d173da23f8972ebc1eda34faf7f591677c94e6eb63fca4af624", 3 | "pins" : [ 4 | { 5 | "identity" : "sourceful", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/maoyama/Sourceful", 8 | "state" : { 9 | "revision" : "78e6a1ba197cf1c409d739633e3bec8084c87723", 10 | "version" : "1.1.0" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitMerge.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitMerge.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2022/10/06. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitMerge: Git { 11 | typealias OutputModel = Void 12 | var arguments: [String] { 13 | [ 14 | "git", 15 | "merge", 16 | branchName, 17 | ] 18 | } 19 | var directory: URL 20 | var branchName: String 21 | 22 | func parse(for stdOut: String) -> Void {} 23 | } 24 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitRestore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitRestore.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/08/31. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitRestore: Git { 11 | typealias OutputModel = Void 12 | var arguments: [String] { 13 | [ 14 | "git", 15 | "restore", 16 | "--staged", 17 | ".", 18 | ] 19 | } 20 | var directory: URL 21 | 22 | func parse(for stdOut: String) -> Void {} 23 | } 24 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitSwitch.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitSwitch.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2022/10/02. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitSwitch: Git { 11 | typealias OutputModel = Void 12 | var arguments: [String] { 13 | [ 14 | "git", 15 | "switch", 16 | branchName, 17 | ] 18 | } 19 | var directory: URL 20 | var branchName: String 21 | 22 | func parse(for stdOut: String) -> Void {} 23 | } 24 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitAddPatch.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitAddPatch.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/03/02. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitAddPatch: InteractiveGit { 11 | typealias OutputModel = Void 12 | var arguments: [String] { 13 | [ 14 | "git", 15 | "add", 16 | "--patch", 17 | ] 18 | } 19 | var directory: URL 20 | var inputs: [String] 21 | 22 | func parse(for stdOut: String) -> Void {} 23 | } 24 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitAddPathspec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitAddPathspec.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/07. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitAddPathspec: Git { 11 | typealias OutputModel = Void 12 | var arguments: [String] { 13 | [ 14 | "git", 15 | "add", 16 | pathspec, 17 | ] 18 | } 19 | var directory: URL 20 | var pathspec: String 21 | 22 | func parse(for stdOut: String) -> Void {} 23 | } 24 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitCheckout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitCheckout.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/08/12. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitCheckout: Git { 11 | typealias OutputModel = Void 12 | var arguments: [String] { 13 | [ 14 | "git", 15 | "checkout", 16 | commitHash, 17 | ] 18 | } 19 | var directory: URL 20 | var commitHash: String 21 | 22 | func parse(for stdOut: String) -> Void {} 23 | } 24 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitCommit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitCommit.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2022/10/01. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitCommit: Git { 11 | typealias OutputModel = Void 12 | var arguments: [String] { 13 | [ 14 | "git", 15 | "commit", 16 | "-m", 17 | message, 18 | ] 19 | } 20 | var directory: URL 21 | var message: String 22 | 23 | func parse(for stdOut: String) -> Void {} 24 | } 25 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitPull.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitPull.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/03/01. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitPull: Git { 11 | typealias OutputModel = Void 12 | var arguments: [String] { 13 | [ 14 | "git", 15 | "pull", 16 | "origin", 17 | refspec, 18 | ] 19 | } 20 | var directory: URL 21 | var refspec: String 22 | 23 | func parse(for stdOut: String) -> Void {} 24 | } 25 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitPush.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitPush.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2022/09/29. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitPush: Git { 11 | typealias OutputModel = Void 12 | var arguments: [String] { 13 | [ 14 | "git", 15 | "push", 16 | "origin", 17 | refspec, 18 | ] 19 | } 20 | var directory: URL 21 | var refspec = "HEAD" 22 | 23 | func parse(for stdOut: String) -> Void {} 24 | } 25 | -------------------------------------------------------------------------------- /GitClientTests/GitRevParseTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitRevParseTests.swift 3 | // GitClientTests 4 | // 5 | // Created by Makoto Aoyama on 2025/06/12. 6 | // 7 | 8 | import Testing 9 | import Foundation 10 | @testable import Changes 11 | 12 | struct GitRevParseTests { 13 | 14 | @Test func gitPath() async throws { 15 | let gitRevParse = GitRevParse(directory: .testFixture!, gitPath: "MERGE_MSG") 16 | let path = try await Process.output(gitRevParse) 17 | #expect(path.hasSuffix("MERGE_MSG")) 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitStash/GitStashApply.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitStashApply.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/16. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitStashApply: Git { 11 | typealias OutputModel = Void 12 | var arguments: [String] { 13 | [ 14 | "git", 15 | "stash", 16 | "apply", 17 | "\(index)" 18 | ] 19 | } 20 | var directory: URL 21 | var index: Int 22 | 23 | func parse(for stdOut: String) -> Void {} 24 | } 25 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitStash/GitStashDrop.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitStashDrop.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/15. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitStashDrop: Git { 11 | typealias OutputModel = Void 12 | var arguments: [String] { 13 | [ 14 | "git", 15 | "stash", 16 | "drop", 17 | "\(index)" 18 | ] 19 | } 20 | var directory: URL 21 | var index: Int 22 | 23 | func parse(for stdOut: String) -> Void {} 24 | } 25 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitSwitchDetach.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitSwitchDetach.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/08/18. 6 | // 7 | import Foundation 8 | 9 | struct GitSwitchDetach: Git { 10 | typealias OutputModel = Void 11 | var arguments: [String] { 12 | [ 13 | "git", 14 | "switch", 15 | "-d", 16 | branchName, 17 | ] 18 | } 19 | var directory: URL 20 | var branchName: String 21 | 22 | func parse(for stdOut: String) -> Void {} 23 | } 24 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitTagDelete.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitTagDelete.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/21. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitTagDelete: Git { 11 | typealias OutputModel = Void 12 | var arguments: [String] { 13 | [ 14 | "git", 15 | "tag", 16 | "-d", 17 | tagname, 18 | ] 19 | } 20 | var directory: URL 21 | var tagname: String 22 | 23 | func parse(for stdOut: String) throws -> OutputModel {} 24 | } 25 | -------------------------------------------------------------------------------- /GitClient/Models/Stash.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Stasg.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/14. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Stash: Identifiable, Hashable { 11 | var id: Int { index } 12 | var index: Int 13 | var message: String 14 | var raw: String 15 | 16 | init(index: Int, raw: String) { 17 | self.index = index 18 | self.raw = raw 19 | self.message = String(raw.split(separator: ":", maxSplits: 1).map { String($0) }[safe: 1]?.dropFirst() ?? "") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /GitClient/Views/StashChanged/StashChangedDetailContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StashChangedDetailContentView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/15. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct StashChangedDetailContentView: View { 11 | @Binding var fileDiffs: [ExpandableModel] 12 | 13 | var body: some View { 14 | VStack { 15 | FileDiffsView(expandableFileDiffs: $fileDiffs) 16 | .padding() 17 | } 18 | .textSelection(.enabled) 19 | } 20 | } 21 | 22 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitTag.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitTag.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/21. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitTag: Git { 11 | typealias OutputModel = [String] 12 | var arguments: [String] { 13 | [ 14 | "git", 15 | "tag", 16 | "--no-column", 17 | ] 18 | } 19 | var directory: URL 20 | 21 | func parse(for stdOut: String) throws -> [String] { 22 | stdOut.components(separatedBy: .newlines).dropLast() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitRestorePatch.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitRestorePatch.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/05. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitRestorePatch: InteractiveGit { 11 | typealias OutputModel = Void 12 | var arguments: [String] { 13 | [ 14 | "git", 15 | "restore", 16 | "--staged", 17 | "--patch", 18 | ] 19 | } 20 | var directory: URL 21 | var inputs: [String] 22 | 23 | func parse(for stdOut: String) -> Void {} 24 | } 25 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitCommitAmend.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitCommitAmend.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/08/11. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitCommitAmend: Git { 11 | typealias OutputModel = Void 12 | var arguments: [String] { 13 | [ 14 | "git", 15 | "commit", 16 | "--amend", 17 | "-m", 18 | message, 19 | ] 20 | } 21 | var directory: URL 22 | var message: String 23 | 24 | func parse(for stdOut: String) -> Void {} 25 | } 26 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitTagCreate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitTagCraete.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/21. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitTagCreate: Git { 11 | typealias OutputModel = Void 12 | var arguments: [String] { 13 | [ 14 | "git", 15 | "tag", 16 | tagname, 17 | object, 18 | ] 19 | } 20 | var directory: URL 21 | var tagname: String 22 | var object: String 23 | 24 | func parse(for stdOut: String) throws -> OutputModel {} 25 | } 26 | -------------------------------------------------------------------------------- /GitClient/Models/DiffStat.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DiffStat.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/07. 6 | // 7 | 8 | import Foundation 9 | 10 | struct DiffStat { 11 | var files: [String] 12 | var insertions: [Int] 13 | var insertionsTotal: Int { 14 | insertions.reduce(0) { partialResult, e in 15 | partialResult + e 16 | } 17 | } 18 | var deletions: [Int] 19 | var deletionsTotal: Int { 20 | deletions.reduce(0) { partialResult, e in 21 | partialResult + e 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /GitClient/Models/Status.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Status.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/06. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Status: Hashable { 11 | var untrackedFiles: [String] 12 | var untrackedFilesShortStat: String { 13 | if untrackedFiles.isEmpty { 14 | return "" 15 | } else if untrackedFiles.count == 1 { 16 | return "1 untracked file" 17 | } else { 18 | return "\(untrackedFiles.count) untracked files" 19 | } 20 | } 21 | var unmergedFiles: [String] 22 | } 23 | -------------------------------------------------------------------------------- /GitClientTests/GitShowShortstatTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitShowShortstat.swift 3 | // GitClientTests 4 | // 5 | // Created by Makoto Aoyama on 2025/04/05. 6 | // 7 | 8 | import Testing 9 | import Foundation 10 | @testable import Changes 11 | 12 | struct GitShowShortstatTests { 13 | @Test func output() async throws { 14 | let stat = GitShowShortstat(directory: .testFixture!, object: "e129fc78f4a907d6977af32985071d773cd6f4fa") 15 | let string = try await Process.output(stat) 16 | #expect(string == "4 files changed, 9 insertions(+), 9 deletions(-)") 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /GitClientTests/BranchTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BranchTests.swift 3 | // GitClientTests 4 | // 5 | // Created by Makoto Aoyama on 2024/09/19. 6 | // 7 | 8 | import XCTest 9 | @testable import Changes 10 | 11 | final class BranchTests: XCTestCase { 12 | func testPoint() throws { 13 | let branch = Branch(name: "main", isCurrent: true) 14 | XCTAssertEqual(branch.point, "main") 15 | } 16 | 17 | func testPointForDetached() throws { 18 | let branch = Branch(name: "(HEAD detached at a036829)", isCurrent: true) 19 | XCTAssertEqual(branch.point, "a036829") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitCheckoutB.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitCheckoutB.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2022/10/06. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitCheckoutB: Git { 11 | typealias OutputModel = Void 12 | var arguments: [String] { 13 | [ 14 | "git", 15 | "checkout", 16 | "-b", 17 | newBranchName, 18 | startPoint, 19 | ] 20 | } 21 | var directory: URL 22 | var newBranchName: String 23 | var startPoint: String 24 | 25 | func parse(for stdOut: String) -> Void {} 26 | } 27 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/DiffTabView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DiffTabView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/05/18. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DiffTabView: View { 11 | @Binding var tab: Int 12 | 13 | var body: some View { 14 | HStack { 15 | Spacer() 16 | Picker("", selection: $tab) { 17 | Text("Commits").tag(0) 18 | Text("Files Changed").tag(1) 19 | } 20 | .frame(maxWidth:400) 21 | .pickerStyle(.segmented) 22 | Spacer() 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /GitClient/Models/DefaultMergeCommitMessage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultMergeCommitMessage.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/06/13. 6 | // 7 | 8 | import Foundation 9 | 10 | struct DefaultMergeCommitMessage { 11 | var directory: URL 12 | 13 | func get() async throws -> String { 14 | let path = try await Process.output(GitRevParse(directory: directory, gitPath: "MERGE_MSG")) 15 | let output = try await Process.output(arguments: ["cat", path], currentDirectoryURL: directory) 16 | return output.standardOutput.trimmingCharacters(in: .whitespacesAndNewlines) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitTagPointsAt.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitTagPointsAt.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/19. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitTagPointsAt: Git { 11 | typealias OutputModel = [String] 12 | var arguments: [String] { 13 | [ 14 | "git", 15 | "tag", 16 | "--points-at", 17 | object 18 | ] 19 | } 20 | var directory: URL 21 | var object: String 22 | 23 | func parse(for stdOut: String) throws -> [String] { 24 | stdOut.components(separatedBy: .newlines).dropLast() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitBranchRename.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitBranchRename.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/04/08. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitBranchRename: Git { 11 | var arguments: [String] { 12 | var arg = [ 13 | "git", 14 | "branch", 15 | "-m", 16 | ] 17 | arg.append(oldBranchName) 18 | arg.append(newBranchName) 19 | return arg 20 | } 21 | var directory: URL 22 | var oldBranchName: String 23 | var newBranchName: String 24 | 25 | func parse(for stdOut: String) throws -> Void {} 26 | } 27 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/SectionHeader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SectionHeader.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/08. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SectionHeader: View { 11 | var title: String 12 | var callout: String 13 | 14 | var body: some View { 15 | HStack { 16 | Text(title) 17 | .font(.title) 18 | .fontWeight(.bold) 19 | .textSelection(.disabled) 20 | Spacer() 21 | Text(callout) 22 | .font(.callout) 23 | .foregroundStyle(.tertiary) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitStash/GitStashShowDiff.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitStashShowDiff.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/15. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitStashShowDiff: Git { 11 | typealias OutputModel = String 12 | var arguments: [String] { 13 | [ 14 | "git", 15 | "stash", 16 | "show", 17 | "--include-untracked", 18 | "-p", 19 | "\(index)", 20 | ] 21 | } 22 | var directory: URL 23 | var index: Int 24 | 25 | func parse(for stdOut: String) -> String { 26 | stdOut 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitBranchDelete.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitBranchDelete.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/04/07. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitBranchDelete: Git { 11 | var arguments: [String] { 12 | var arg = [ 13 | "git", 14 | "branch", 15 | "--delete", 16 | ] 17 | if isRemote { 18 | arg.append("-r") 19 | } 20 | arg.append(branchName) 21 | return arg 22 | } 23 | var directory: URL 24 | var isRemote = false 25 | var branchName: String 26 | 27 | func parse(for stdOut: String) throws -> Void {} 28 | } 29 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitRevListCount.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitRevListCount.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/04/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitRevListCount: Git { 11 | typealias OutputModel = Int? 12 | var arguments: [String] { 13 | var args = [ 14 | "git", 15 | "rev-list", 16 | "--count" 17 | ] 18 | args.append(commit) 19 | return args 20 | } 21 | var directory: URL 22 | var commit = "HEAD" 23 | 24 | func parse(for stdOut: String) -> Int? { 25 | Int(stdOut.trimmingCharacters(in: .whitespacesAndNewlines)) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitStash/GitStash.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitStash.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/15. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitStash: Git { 11 | typealias OutputModel = Void 12 | var arguments: [String] { 13 | var args = [ 14 | "git", 15 | "stash", 16 | "--include-untracked", 17 | ] 18 | if !message.isEmpty { 19 | args.append("-m") 20 | args.append(message) 21 | } 22 | return args 23 | } 24 | var directory: URL 25 | var message = "" 26 | 27 | func parse(for stdOut: String) -> Void {} 28 | } 29 | -------------------------------------------------------------------------------- /GitClient/Models/Log.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Log.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2022/10/02. 6 | // 7 | 8 | import Foundation 9 | 10 | enum Log: Identifiable, Hashable { 11 | var id: String { 12 | switch self { 13 | case .notCommitted: 14 | return "notCommitted" 15 | case .committed(let c): 16 | return c.hash 17 | } 18 | } 19 | 20 | case notCommitted, committed(Commit) 21 | } 22 | 23 | struct NotCommitted: Hashable { 24 | var diff: String 25 | var diffCached: String 26 | var status: Status 27 | var isEmpty: Bool { (diff + diffCached).isEmpty && status.untrackedFiles.isEmpty } 28 | } 29 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/StageFileDiffHeaderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StageFileDiffHeaderView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/02/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct StageFileDiffHeaderView: View { 11 | var fileDiff: FileDiff 12 | 13 | var body: some View { 14 | HStack { 15 | FileNameView(toFilePath: fileDiff.toFilePath, filePathDisplay: fileDiff.filePathDisplay) 16 | .help(fileDiff.header + "\n" + (fileDiff.extendedHeaderLines + fileDiff.fromFileToFileLines).joined(separator: "\n")) 17 | Spacer() 18 | } 19 | .background(Color(NSColor.textBackgroundColor).opacity(0.98)) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitDiffShortStat.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitDiffShortStat.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/07. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitDiffShortStat: Git { 11 | typealias OutputModel = String 12 | var arguments: [String] { 13 | var args = [ 14 | "git", 15 | "diff", 16 | "--no-renames", 17 | "--shortstat", 18 | ] 19 | if cached { 20 | args.append("--cached") 21 | } 22 | return args 23 | } 24 | var directory: URL 25 | var cached = false 26 | 27 | func parse(for stdOut: String) -> String { 28 | stdOut 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitRevParse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitRevParse.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/06/12. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitRevParse: Git { 11 | typealias OutputModel = String 12 | var arguments: [String] { 13 | var args = [ 14 | "git", 15 | "rev-parse", 16 | ] 17 | if !gitPath.isEmpty { 18 | args.append("--git-path") 19 | args.append(gitPath) 20 | } 21 | return args 22 | } 23 | var directory: URL 24 | var gitPath: String 25 | 26 | func parse(for stdOut: String) -> String { 27 | stdOut.trimmingCharacters(in: .whitespacesAndNewlines) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitStash/GitStashList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitStashList.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/14. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitStashList: Git { 11 | typealias OutputModel = [Stash] 12 | let arguments = [ 13 | "git", 14 | "stash", 15 | "list" 16 | ] 17 | var directory: URL 18 | 19 | func parse(for stdOut: String) throws -> [Stash] { 20 | guard !stdOut.isEmpty else { return [] } 21 | let stashList = stdOut.components(separatedBy: .newlines).filter { !$0.isEmpty } 22 | return stashList.enumerated().map { i, stash in 23 | return Stash(index: i, raw: stash) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitShowShortstat.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitShowShortstat.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/04/05. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitShowShortstat: Git { 11 | var arguments: [String] { 12 | [ 13 | "git", 14 | "show", 15 | "--shortstat", 16 | object 17 | ] 18 | } 19 | var directory: URL 20 | var object: String 21 | 22 | func parse(for stdOut: String) throws -> String { 23 | guard !stdOut.isEmpty else { throw GenericError(errorDescription: "Parse error: stdOut is empty.") } 24 | return String(stdOut.split(separator: "\n").last ?? "").trimmingCharacters(in: .whitespaces) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/BarBackButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BarBackButton.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/05/20. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BarBackButton: View { 11 | @Binding var path: [String] 12 | 13 | var body: some View { 14 | HStack { 15 | Button { 16 | path = path.dropLast() 17 | } label: { 18 | Image(systemName: "chevron.backward") 19 | } 20 | .background(Color(NSColor.textBackgroundColor).opacity(1)) 21 | .padding(.horizontal) 22 | .padding(.vertical, 8) 23 | Spacer() 24 | } 25 | .background(Color(NSColor.textBackgroundColor).opacity(0.98)) } 26 | } 27 | -------------------------------------------------------------------------------- /GitClient/Models/Branch.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Branch.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2022/10/02. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Branch: Hashable, Identifiable { 11 | let detachedPrefix = "(HEAD detached at " 12 | 13 | var id: String { 14 | name 15 | } 16 | var name: String 17 | var isCurrent: Bool 18 | var point: String { 19 | if isDetached { 20 | return String(name.dropFirst(detachedPrefix.count).dropLast(1)) 21 | } 22 | return name 23 | } 24 | var isDetached: Bool { 25 | return name.hasPrefix(detachedPrefix) 26 | } 27 | } 28 | 29 | extension [Branch] { 30 | var current: Branch? { 31 | first { $0.isCurrent } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /GitClient/Models/ExpandableModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExpandedModel.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/03/18. 6 | // 7 | import Foundation 8 | 9 | struct ExpandableModel: Hashable { 10 | var isExpanded: Bool 11 | var model: Model 12 | } 13 | 14 | extension Array where Element: Hashable { 15 | func withExpansionState(from old: [ExpandableModel]) -> [ExpandableModel] { 16 | self.map { model in 17 | if let oldModel = old.first(where: { $0.model == model }) { 18 | return ExpandableModel(isExpanded: oldModel.isExpanded, model: model) 19 | } else { 20 | return ExpandableModel(isExpanded: true, model: model) 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitShowref.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitShowref.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/04/10. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitShowref: Git { 11 | var arguments: [String] { 12 | [ 13 | "git", 14 | "show-ref", 15 | pattern 16 | ] 17 | } 18 | var directory: URL 19 | /// Show references matching one or more patterns. Patterns are matched from the end of the full name, and only complete parts are matched, e.g. master matches refs/heads/master, refs/remotes/origin/master, refs/tags/jedi/master but not refs/heads/mymaster or refs/remotes/master/jedi. 20 | var pattern: String 21 | 22 | func parse(for stdOut: String) -> String { 23 | stdOut 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/DiffView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DiffView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/05/18. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DiffView: View { 11 | @Binding var commits: [Commit] 12 | @Binding var filesChanged: [ExpandableModel] 13 | @Binding var tab: Int 14 | 15 | var body: some View { 16 | VStack(alignment: .leading, spacing: 0) { 17 | DiffTabView(tab: $tab) 18 | .padding(.top) 19 | if tab == 0 { 20 | DiffCommitListView(commits: commits) 21 | .padding(.vertical) 22 | } 23 | if tab == 1 { 24 | FileDiffsView(expandableFileDiffs: $filesChanged) 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /GitClientTests/GitStatusTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitStatusTests.swift 3 | // GitClientTests 4 | // 5 | // Created by Makoto Aoyama on 2025/06/06. 6 | // 7 | 8 | import Testing 9 | @testable import Changes 10 | import Foundation 11 | 12 | struct GitStatusTests { 13 | 14 | @Test func parse() async throws { 15 | let git = GitStatus(directory: URL(string: "file:///hoge")!) 16 | let status = git.parse(for: """ 17 | UU Examples/Examples/ContentView.swift 18 | UU README.md 19 | M Sources/SyntaxHighlight/Text+Init.swift 20 | ?? Sources/SyntaxHighlight/Hoge+Init.swift 21 | """) 22 | #expect(status.unmergedFiles == ["Examples/Examples/ContentView.swift", "README.md"]) 23 | #expect(status.untrackedFiles == ["Sources/SyntaxHighlight/Hoge+Init.swift"]) 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/CommitDetailStackView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommitDetailStackView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/10/11. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CommitDetailStackView: View { 11 | @State private var path: [String] = [] 12 | var commit: Commit 13 | var folder: Folder 14 | 15 | var body: some View { 16 | NavigationStack(path: $path) { 17 | CommitDetailContentView(commit: commit, folder: folder) 18 | .navigationDestination(for: String.self) { commitHash in 19 | CommitDetailView(commitHash: commitHash, folder: folder) 20 | } 21 | } 22 | .onChange(of: commit) { oldValue, newValue in 23 | path = [] 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /GitClient/AIService/StagingChangesProperties.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StagingChangesProperties.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/29. 6 | // 7 | 8 | import Foundation 9 | 10 | struct StagingChangesProperties: Codable { 11 | struct BooleanArray: Codable { 12 | struct Items: Codable { 13 | var type = "boolean" 14 | } 15 | var type = "array" 16 | var items = Items() 17 | } 18 | struct CommitMessage: Codable { 19 | var type = "string" 20 | } 21 | 22 | var hunksToStage = BooleanArray() 23 | var filesToStage = BooleanArray() 24 | var commitMessage = CommitMessage() 25 | } 26 | 27 | struct StagingChanges: Codable { 28 | var hunksToStage: [Bool] 29 | var filesToStage: [Bool] 30 | var commitMessage: String 31 | } 32 | -------------------------------------------------------------------------------- /GitClient/Views/Tags/TagsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TagsView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/21. 6 | // 7 | 8 | 9 | import SwiftUI 10 | 11 | struct TagsView: View { 12 | var folder: Folder 13 | @Binding var showingTags: Bool 14 | @State private var tags: [String]? 15 | @State private var error: Error? 16 | 17 | var body: some View { 18 | TagsContentView(folder: folder, showingTags: $showingTags, tags: $tags) 19 | .frame(width: 300, height: 660) 20 | .task { 21 | do { 22 | tags = try await Process.output(GitTag(directory: folder.url)) 23 | } catch { 24 | self.error = error 25 | } 26 | } 27 | .errorSheet($error) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitBranchPointsAt.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitBranchPointsAt.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/20. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitBranchPointsAt: Git { 11 | typealias OutputModel = [Branch] 12 | var arguments: [String] { 13 | [ 14 | "git", 15 | "branch", 16 | "--all", 17 | "--points-at", 18 | object 19 | ] 20 | } 21 | var directory: URL 22 | var object: String 23 | 24 | func parse(for stdOut: String) throws -> [Branch] { 25 | let lines = stdOut.components(separatedBy: .newlines).dropLast() 26 | return lines.map { line in 27 | Branch(name: String(line.dropFirst(2)), isCurrent: line.hasPrefix("*")) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - Device: [e.g. MacBook Pro M4] 28 | - OS: [e.g. macOS 15] 29 | - Version [e.g. 1.2] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /GitClient/Assets.xcassets/rust.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "rust-lang-logo-black 1.png", 5 | "idiom" : "universal" 6 | }, 7 | { 8 | "appearances" : [ 9 | { 10 | "appearance" : "luminosity", 11 | "value" : "light" 12 | } 13 | ], 14 | "filename" : "rust-lang-logo-black.png", 15 | "idiom" : "universal" 16 | }, 17 | { 18 | "appearances" : [ 19 | { 20 | "appearance" : "luminosity", 21 | "value" : "dark" 22 | } 23 | ], 24 | "filename" : "rust-lang-logo-white.png", 25 | "idiom" : "universal" 26 | } 27 | ], 28 | "info" : { 29 | "author" : "xcode", 30 | "version" : 1 31 | }, 32 | "properties" : { 33 | "preserves-vector-representation" : true 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitBranch.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitBranch.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2022/10/02. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitBranch: Git { 11 | typealias OutputModel = [Branch] 12 | var arguments: [String] { 13 | var arg = [ 14 | "git", 15 | "branch", 16 | "--sort=-authordate", 17 | ] 18 | if isRemote { 19 | arg.append("-r") 20 | } 21 | return arg 22 | } 23 | var directory: URL 24 | var isRemote = false 25 | 26 | func parse(for stdOut: String) throws -> [Branch] { 27 | let lines = stdOut.components(separatedBy: .newlines).dropLast() 28 | return lines.map { line in 29 | Branch(name: String(line.dropFirst(2)), isCurrent: line.hasPrefix("*")) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /GitClient.xcodeproj/xcuserdata/aoyama.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | GitClient.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | SuppressBuildableAutocreation 14 | 15 | 61347EE928D5D16C00625FC4 16 | 17 | primary 18 | 19 | 20 | 61347EFA28D5D16D00625FC4 21 | 22 | primary 23 | 24 | 25 | 61347F0428D5D16D00625FC4 26 | 27 | primary 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | jobs: 9 | build: 10 | name: Test 11 | permissions: 12 | contents: read 13 | pull-requests: write 14 | runs-on: macos-26 15 | steps: 16 | - name: Select Xcode 17 | run: | 18 | sudo xcode-select -s /Applications/Xcode_26.0.app/Contents/Developer 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | with: 22 | submodules: true 23 | fetch-depth: 0 24 | - name: Check git version 25 | run: | 26 | git --version 27 | - name: Test 28 | run: | 29 | SRCROOT=$GITHUB_WORKSPACE xcodebuild clean test -scheme GitClient CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO -destination "platform=macOS" -disableAutomaticPackageResolution 30 | -------------------------------------------------------------------------------- /GitClient/AppIcon.icon/Assets/Oval.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Oval 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitRevert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitRevert.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/21. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitRevert: Git { 11 | typealias OutputModel = Void 12 | var arguments: [String] { 13 | var args = [ 14 | "git", 15 | "revert", 16 | ] 17 | if let parentNumber { 18 | args.append("-m") 19 | args.append(String(parentNumber)) 20 | } 21 | args.append(commit) 22 | return args 23 | } 24 | var directory: URL 25 | var parentNumber: Int? 26 | /// Commits to revert. For a more complete list of ways to spell commit names, see gitrevisions[7]. Sets of commits can also be given but no traversal is done by default, see git-rev-list[1] and its --no-walk option. 27 | var commit: String 28 | 29 | func parse(for stdOut: String) -> Void {} 30 | } 31 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitStatusShort.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitStatus.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/06. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitStatus: Git { 11 | typealias OutputModel = Status 12 | var arguments: [String] { 13 | [ 14 | "git", 15 | "status", 16 | "--short", 17 | ] 18 | } 19 | var directory: URL 20 | 21 | func parse(for stdOut: String) -> Status { 22 | let lines = stdOut.components(separatedBy: .newlines) 23 | // https://git-scm.com/docs/git-status#_short_format 24 | let untrackedLines = lines.filter { $0.hasPrefix("?? ") } 25 | let unmergedLines = lines.filter { $0.hasPrefix("U") } 26 | return .init(untrackedFiles: untrackedLines.map { String($0.dropFirst(3)) }, unmergedFiles: unmergedLines.map { $0.components(separatedBy: .whitespaces).last ?? "" }) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitDiff.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitDiff.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2022/09/26. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitDiff: Git { 11 | typealias OutputModel = String 12 | var arguments: [String] { 13 | var args = [ 14 | "git", 15 | "diff", 16 | ] 17 | if noRenames { 18 | args.append("--no-renames") 19 | } 20 | if shortstat { 21 | args.append("--shortstat") 22 | } 23 | if cached { 24 | args.append("--cached") 25 | } 26 | if !commitRange.isEmpty { 27 | args.append(commitRange) 28 | } 29 | return args 30 | } 31 | var directory: URL 32 | var noRenames = true 33 | var shortstat = false 34 | var cached = false 35 | var commitRange = "" 36 | 37 | func parse(for stdOut: String) -> String { 38 | stdOut 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/CommitDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommitDetailView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/10/08. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CommitDetailView: View { 11 | var commitHash: String 12 | var folder: Folder 13 | @State private var commit: Commit? 14 | @State private var error: Error? 15 | 16 | var body: some View { 17 | VStack { 18 | if let commit { 19 | CommitDetailContentView(commit: commit, folder: folder) 20 | } 21 | } 22 | .frame(maxWidth: .infinity, maxHeight: .infinity) 23 | .background(Color(NSColor.textBackgroundColor)) 24 | .task { 25 | do { 26 | commit = try await Process.output(GitShow(directory: folder.url, object: commitHash)).commit 27 | } catch { 28 | self.error = error 29 | } 30 | } 31 | .errorSheet($error) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /GitClientUITests/GitClientUITestsLaunchTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitClientUITestsLaunchTests.swift 3 | // GitClientUITests 4 | // 5 | // Created by Makoto Aoyama on 2022/09/17. 6 | // 7 | 8 | import XCTest 9 | 10 | final class GitClientUITestsLaunchTests: XCTestCase { 11 | 12 | override class var runsForEachTargetApplicationUIConfiguration: Bool { 13 | true 14 | } 15 | 16 | override func setUpWithError() throws { 17 | continueAfterFailure = false 18 | } 19 | 20 | func testLaunch() throws { 21 | let app = XCUIApplication() 22 | app.launch() 23 | 24 | // Insert steps here to perform after app launch but before taking a screenshot, 25 | // such as logging into a test account or navigating somewhere in the app 26 | 27 | let attachment = XCTAttachment(screenshot: app.screenshot()) 28 | attachment.name = "Launch Screen" 29 | attachment.lifetime = .keepAlways 30 | add(attachment) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /GitClientTests/GitDiffNumStatTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitDiffNumStatTests.swift 3 | // GitClientTests 4 | // 5 | // Created by Makoto Aoyama on 2024/09/07. 6 | // 7 | 8 | import XCTest 9 | @testable import Changes 10 | 11 | final class GitDiffNumStatTests: XCTestCase { 12 | func testParse() throws { 13 | let c = GitDiffNumStat(directory: URL(string: "file:///maoyama/Projects/")!) 14 | let stat = c.parse(for: """ 15 | 3\t0\tJot/HistoryDetailNote.swift 16 | 0\t20\tJot/Note.swift 17 | 20\t0\tJot/Note.wift 18 | 3\t0\ttestfile5.swif 19 | -\t-\ttestfile6.bin 20 | """) 21 | XCTAssertEqual( 22 | stat.files, 23 | ["Jot/HistoryDetailNote.swift", "Jot/Note.swift", "Jot/Note.wift", "testfile5.swif", "testfile6.bin"] 24 | ) 25 | XCTAssertEqual( 26 | stat.insertions, 27 | [3, 0, 20, 3, 0] 28 | ) 29 | XCTAssertEqual( 30 | stat.deletions, 31 | [0, 20, 0, 0, 0] 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /GitClient/Extensions/URL+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URL+.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/10/26. 6 | // 7 | 8 | import Foundation 9 | import CryptoKit 10 | 11 | extension URL { 12 | static var testFixture: URL? { 13 | guard let srcroot = ProcessInfo.processInfo.environment["SRCROOT"] else { return nil } 14 | return URL(fileURLWithPath: srcroot).appending(path: "TestFixtures").appending(path: "SyntaxHighlight") 15 | } 16 | 17 | static func gravater(email: String, size: CGFloat=80) -> URL? { 18 | guard let data = email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().data(using: .utf8) else { 19 | return nil 20 | } 21 | let hashedData = SHA256.hash(data: data) 22 | let hashString = hashedData.compactMap { String(format: "%02x", $0) }.joined() 23 | return URL(string: "https://gravatar.com/avatar/" + hashString + "?d=404&size=\(size)") // https://docs.gravatar.com/api/avatars/images/ 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /GitClient/Views/Folder/CommitRowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommitRowView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/04/10. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CommitRowView: View { 11 | var commit: Commit 12 | 13 | var body: some View { 14 | VStack (alignment: .leading) { 15 | HStack(alignment: .firstTextBaseline) { 16 | Text(commit.title) 17 | Spacer() 18 | if !commit.tags.isEmpty { 19 | Image(systemName: "tag") 20 | .foregroundStyle(.tertiary) 21 | .font(.caption) 22 | } 23 | } 24 | HStack { 25 | Icon(size: .small, authorEmail: commit.authorEmail, authorInitial: String(commit.author.initial.prefix(1))) 26 | Text(commit.author) 27 | Spacer() 28 | Text(commit.authorDateRelative) 29 | } 30 | .lineLimit(1) 31 | .foregroundStyle(.tertiary) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/FileDiffView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileDiffView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/03/16. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FileDiffView: View { 11 | @Binding var expandableFileDiff: ExpandableModel 12 | 13 | var body: some View { 14 | DisclosureGroup(isExpanded: $expandableFileDiff.isExpanded) { 15 | LazyVStack(alignment: .leading, spacing: 0, pinnedViews: .sectionHeaders) { 16 | chunksView(expandableFileDiff.model.chunks, filePath: expandableFileDiff.model.toFilePath) 17 | .padding(.top, 8) 18 | .padding(.bottom, 12) 19 | } 20 | } label: { 21 | FileNameView(toFilePath: expandableFileDiff.model.toFilePath, filePathDisplay: expandableFileDiff.model.filePathDisplay) 22 | .padding(.leading, 3) 23 | } 24 | } 25 | 26 | private func chunksView(_ chunks: [Chunk], filePath: String) -> some View { 27 | ForEach(chunks) { chunk in 28 | ChunkView(chunk: chunk, filePath: filePath) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Makoto Aoyama 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /GitClient/Views/Folder/AuthorInitialIcon.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthorInitialIcon.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/05/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AuthorInitialIcon: View { 11 | var initial: String 12 | var body: some View { 13 | ZStack { 14 | Rectangle() 15 | .fill( 16 | LinearGradient( 17 | gradient: Gradient(colors: [Color(white: 0.70), Color(white: 0.50)]), 18 | startPoint: .top, 19 | endPoint: .bottom 20 | ) 21 | ) 22 | Text(initial) 23 | .fontDesign(.rounded) 24 | .foregroundColor(.white) 25 | .textSelection(.disabled) 26 | } 27 | } 28 | } 29 | 30 | #Preview { 31 | AuthorInitialIcon(initial: "A") 32 | .frame(width: 40, height: 40) 33 | } 34 | 35 | #Preview { 36 | AuthorInitialIcon(initial: "AA") 37 | .frame(width: 40, height: 40) 38 | } 39 | 40 | #Preview { 41 | AuthorInitialIcon(initial: "AAAAAAAAA") 42 | .frame(width: 40, height: 40) 43 | } 44 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitDiffNumStat.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitDiffNumStat.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/07. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitDiffNumStat: Git { 11 | typealias OutputModel = DiffStat 12 | var arguments: [String] { 13 | var args = [ 14 | "git", 15 | "diff", 16 | "--no-renames", 17 | "--numstat", 18 | ] 19 | if cached { 20 | args.append("--cached") 21 | } 22 | return args 23 | } 24 | var directory: URL 25 | var cached = false 26 | 27 | func parse(for stdOut: String) -> DiffStat { 28 | let lines = stdOut.components(separatedBy: .newlines).filter { !$0.isEmpty } 29 | let splitted = lines.map { line in 30 | line.components(separatedBy: .whitespaces).filter { !$0.isEmpty } 31 | } 32 | return DiffStat( 33 | files: splitted.map { String($0[safe: 2] ?? "") }, 34 | insertions: splitted.map { Int($0[safe: 0] ?? "0") ?? 0 }, 35 | deletions: splitted.map { Int($0[safe: 1] ?? "0") ?? 0 } 36 | ) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /GitClient/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "1x", 46 | "size" : "512x512" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "2x", 51 | "size" : "512x512" 52 | } 53 | ], 54 | "info" : { 55 | "author" : "xcode", 56 | "version" : 1 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /GitClient/Views/StashChanged/StashChangedView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StashChangedView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/14. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct StashChangedView: View { 11 | var folder: Folder 12 | @Binding var showingStashChanged: Bool 13 | @State private var stashList: [Stash]? 14 | @State private var error: Error? 15 | 16 | var body: some View { 17 | StashChangedContentView(folder: folder, showingStashChanged: $showingStashChanged, stashList: stashList, onTapDropButton: { stash in 18 | Task { 19 | do { 20 | try await Process.output(GitStashDrop(directory: folder.url, index: stash.index)) 21 | stashList = try await Process.output(GitStashList(directory: folder.url)) 22 | } catch { 23 | self.error = error 24 | } 25 | } 26 | }) 27 | .task { 28 | do { 29 | stashList = try await Process.output(GitStashList(directory: folder.url)) 30 | } catch { 31 | self.error = error 32 | } 33 | } 34 | .errorSheet($error) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /GitClient/GitClientApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitClientApp.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2022/09/17. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct GitClientApp: App { 12 | @State private var expandAllFiles: UUID? 13 | @State private var collapseAllFiles: UUID? 14 | 15 | var body: some Scene { 16 | WindowGroup { 17 | ContentView() 18 | .environment(\.expandAllFiles, expandAllFiles) 19 | .environment(\.collapseAllFiles, collapseAllFiles) 20 | } 21 | .commands { 22 | CommandGroup(before: .toolbar) { 23 | Button("Expand All Files") { 24 | expandAllFiles = UUID() 25 | } 26 | .keyboardShortcut(.rightArrow, modifiers: .option) 27 | Button("Collapse All Files") { 28 | collapseAllFiles = UUID() 29 | } 30 | .keyboardShortcut(.leftArrow, modifiers: .option) 31 | Divider() 32 | } 33 | } 34 | 35 | Window("Commit Message Snippets", id: WindowID.commitMessageSnippets.rawValue) { 36 | CommitMessageSnippetView() 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/FileDiffsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileDiffsView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/08/04. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FileDiffsView: View { 11 | @Binding var expandableFileDiffs: [ExpandableModel] 12 | @Environment(\.expandAllFiles) private var expandAllFilesID: UUID? 13 | @Environment(\.collapseAllFiles) private var collapseAllFilesID: UUID? 14 | 15 | var body: some View { 16 | LazyVStack(alignment: .leading, spacing: 0) { 17 | ForEach($expandableFileDiffs, id: \.self) { $expandedFileDiff in 18 | FileDiffView(expandableFileDiff: $expandedFileDiff) 19 | } 20 | .padding(.top, 2) 21 | .onChange(of: expandAllFilesID) { _, _ in 22 | expandableFileDiffs = expandableFileDiffs.map { ExpandableModel(isExpanded: true, model: $0.model)} 23 | } 24 | .onChange(of: collapseAllFilesID) { _, _ in 25 | expandableFileDiffs = expandableFileDiffs.map { ExpandableModel(isExpanded: false, model: $0.model)} 26 | } 27 | } 28 | .font(Font.system(.body, design: .monospaced)) 29 | .padding(.top) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /GitClient/Models/Observables/SyncState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SyncState.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/04/10. 6 | // 7 | 8 | import Foundation 9 | import Observation 10 | 11 | @MainActor 12 | @Observable class SyncState { 13 | var folderURL: URL? 14 | var branch: Branch? 15 | var shouldPull = false 16 | var shouldPush = false 17 | 18 | func sync() async throws { 19 | guard let folderURL, let branch, !branch.isDetached else { 20 | shouldPull = false 21 | shouldPush = false 22 | return 23 | } 24 | try await GitFetchExecutor.shared.execute(GitFetch(directory: folderURL)) 25 | 26 | let existRemoteBranch = try? await Process.output(GitShowref(directory: folderURL, pattern: "refs/remotes/origin/\(branch.name)")) 27 | guard existRemoteBranch != nil else { 28 | shouldPull = false 29 | shouldPush = true 30 | return 31 | } 32 | shouldPull = !(try await Process.output(GitLog(directory: folderURL, revisionRange: ["\(branch.name)..origin/\(branch.name)"])).isEmpty) 33 | shouldPush = !(try await Process.output(GitLog(directory: folderURL, revisionRange: ["origin/\(branch.name)..\(branch.name)"])).isEmpty) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/CommitMessageGenerationView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommitMessageGenerationView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/08/16. 6 | // 7 | 8 | import SwiftUI 9 | import FoundationModels 10 | 11 | struct CommitMessageGenerationView: View { 12 | @Environment(\.systemLanguageModelAvailability) private var systemLanguageModelAvailability 13 | @Binding var cachedDiffRaw: String 14 | @Binding var commitMessage: String 15 | @Binding var commitMessageIsReponding: Bool 16 | @Binding var generatedCommitMessage: String 17 | 18 | var body: some View { 19 | HStack { 20 | switch systemLanguageModelAvailability { 21 | case .available: 22 | CommitMessageGenerationContentView( 23 | cachedDiffRaw: $cachedDiffRaw, 24 | commitMessage: $commitMessage, 25 | commitMessageIsReponding: $commitMessageIsReponding, 26 | generatedCommitMessage: $generatedCommitMessage 27 | ) 28 | case .unavailable(let reason): 29 | HStack { 30 | Spacer() 31 | CommitMessageGenerationUnavailableView(reason: reason) 32 | }.buttonStyle(.plain) 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /GitClient.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "20703B03-9DC8-43F5-B206-DEB351093A17", 5 | "name" : "Test Scheme Action", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "environmentVariableEntries" : [ 13 | { 14 | "key" : "SRCROOT", 15 | "value" : "$(SRCROOT)" 16 | } 17 | ], 18 | "targetForVariableExpansion" : { 19 | "containerPath" : "container:GitClient.xcodeproj", 20 | "identifier" : "61347EE928D5D16C00625FC4", 21 | "name" : "GitClient" 22 | } 23 | }, 24 | "testTargets" : [ 25 | { 26 | "parallelizable" : false, 27 | "skippedTests" : { 28 | "suites" : [ 29 | { 30 | "name" : "SystemLanguageModelServiceTests" 31 | } 32 | ] 33 | }, 34 | "target" : { 35 | "containerPath" : "container:GitClient.xcodeproj", 36 | "identifier" : "61347EFA28D5D16D00625FC4", 37 | "name" : "GitClientTests" 38 | } 39 | }, 40 | { 41 | "enabled" : false, 42 | "parallelizable" : true, 43 | "target" : { 44 | "containerPath" : "container:GitClient.xcodeproj", 45 | "identifier" : "61347F0428D5D16D00625FC4", 46 | "name" : "GitClientUITests" 47 | } 48 | } 49 | ], 50 | "version" : 1 51 | } 52 | -------------------------------------------------------------------------------- /GitClientTests/DefaultMergeCommitMessageTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultMergeCommitMessageTests.swift 3 | // GitClientTests 4 | // 5 | // Created by Makoto Aoyama on 2025/06/14. 6 | // 7 | 8 | import Testing 9 | @testable import Changes 10 | import Foundation 11 | 12 | struct DefaultMergeCommitMessageTests { 13 | 14 | @Test func get() async throws { 15 | try await Process.output(GitSwitch(directory: .testFixture!, branchName: "_test-fixture-conflict")) 16 | try await Process.output(GitSwitch(directory: .testFixture!, branchName: "_test-fixture-conflict2")) 17 | do { 18 | try await Process.output(GitMerge(directory: .testFixture!, branchName: "_test-fixture-conflict")) 19 | } catch { 20 | // A conflict error occurred 21 | print(error) 22 | } 23 | let string = try await DefaultMergeCommitMessage(directory: .testFixture!).get() 24 | let expectedString = """ 25 | Merge branch '_test-fixture-conflict' into _test-fixture-conflict2 26 | 27 | # Conflicts: 28 | #\tExamples/Examples/ContentView.swift 29 | #\tREADME.md 30 | """ 31 | 32 | #expect(string == expectedString) 33 | 34 | // Cleanup 35 | try await Process.output(GitAdd(directory: .testFixture!)) 36 | try await Process.output(GitStash(directory: .testFixture!)) 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Changes - An Open Source GUI Git Client for macOS 2 | 3 | Changes.app is a modern Git client for Mac, built with SwiftUI and AppKit and styled with Apple’s new Liquid Glass design. 4 | It replaces complex CLI commands with a clear, Mac-native interface that keeps cognitive load low, so you can focus on what really matters—coding. 5 | With commit message generation powered by Apple Intelligence, creating clear and meaningful commits becomes effortless. 6 | 7 | ![Screenshot](Screenshots/Screenshot2-1.png) 8 | ![Screenshot](Screenshots/Screenshot2-2.png) 9 | 10 | ## Features 11 | 12 | This app provides the following features: 13 | - Commit 14 | - Amend 15 | - Commit Message Generation using Apple Intelligence 16 | - Commit Message Snippets 17 | - Revert 18 | - Checkout 19 | - Add 20 | - Stage by Hunk 21 | - Branch 22 | - Push 23 | - Pull 24 | - Merge 25 | - Create 26 | - Filter 27 | - Tag 28 | - Create & Push 29 | - Delete 30 | - Filter 31 | - Stash 32 | - Apply 33 | - Summary Generation using Apple Intelligence 34 | - Search Commits 35 | - Commit Message 36 | - Changed (added/removed) 37 | - Author 38 | - Revision Range (e.g., main.., v1.0.0...v2.0.0) 39 | 40 | ## Installation 41 | 42 | Download the latest [release](https://github.com/maoyama/Changes/releases), unzip, and run the app. 43 | 44 | - v2.0+ (macOS 26.0 or later) 45 | - [v1.18](https://github.com/maoyama/Changes/releases/tag/v1.18) and below (macOS 14.6 or later) - Tempo.app (old name for v1.x) 46 | -------------------------------------------------------------------------------- /GitClient/Models/Commit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Commit.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2022/09/18. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Commit: Hashable, Identifiable { 11 | var id: String { hash } 12 | var hash: String 13 | var parentHashes: [String] 14 | var author: String 15 | var authorEmail: String 16 | var authorDate: String 17 | var authorDateDisplay: String { 18 | guard let date = ISO8601DateFormatter().date(from: authorDate) else { 19 | return "" 20 | } 21 | return DateFormatter.localizedString(from: date, dateStyle: .long, timeStyle: .long) 22 | } 23 | var authorDateDisplayShort: String { 24 | guard let date = ISO8601DateFormatter().date(from: authorDate) else { 25 | return "" 26 | } 27 | return DateFormatter.localizedString(from: date, dateStyle: .short, timeStyle: .short) 28 | } 29 | var authorDateRelative: String { 30 | guard let date = ISO8601DateFormatter().date(from: authorDate) else { 31 | return "" 32 | } 33 | let formatter = RelativeDateTimeFormatter() 34 | formatter.dateTimeStyle = .named 35 | return formatter.localizedString(for: date, relativeTo: Date()) 36 | } 37 | var title: String 38 | var body: String 39 | var rawBody: String { 40 | guard !body.isEmpty else { 41 | return title 42 | } 43 | return title + "\n\n" + body 44 | } 45 | var branches: [String] 46 | var tags: [String] 47 | } 48 | -------------------------------------------------------------------------------- /GitClientTests/GitLogTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitLogTests.swift 3 | // GitClientTests 4 | // 5 | // Created by Makoto Aoyama on 2024/10/01. 6 | // 7 | 8 | import Testing 9 | import Foundation 10 | @testable import Changes 11 | 12 | struct GitLogTests { 13 | @Test func parse() async throws { 14 | try await Process.output(GitSwitch(directory: .testFixture!, branchName: "_test-fixture")) 15 | let gitlog = GitLog(directory: .testFixture!) 16 | let commits = try await Process.output(gitlog) 17 | #expect(commits.last!.hash == "f9635d9f3b39534e84f183555274867199af76a3") 18 | #expect(commits.last!.title == "Initial Commit") 19 | #expect(commits.last!.rawBody == "Initial Commit") 20 | #expect(commits.last!.body == "") 21 | #expect(commits.first!.parentHashes == ["cfae9305deac59f24365834d159bbc6fa57812b1", "e129fc78f4a907d6977af32985071d773cd6f4fa"]) 22 | #expect(commits.first!.branches.contains("origin/main")) 23 | #expect(commits.first!.branches.contains("origin/HEAD")) 24 | #expect(commits.first!.tags == ["v0.2.0"]) 25 | #expect(commits.first!.rawBody == """ 26 | Merge pull request #2 from maoyama/fix-typos 27 | 28 | Fix typos 29 | """ 30 | ) 31 | #expect(commits.count == 29) 32 | } 33 | 34 | @Test func parseEmpty() async throws { 35 | let gitlog = GitLog(directory: URL(string: "file:///maoyama/Projects/")!) 36 | let output = "" 37 | let commits = try gitlog.parse(for: output) 38 | #expect(commits.count == 0) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /GitClientUITests/GitClientUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitClientUITests.swift 3 | // GitClientUITests 4 | // 5 | // Created by Makoto Aoyama on 2022/09/17. 6 | // 7 | 8 | import XCTest 9 | 10 | final class GitClientUITests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | 15 | // In UI tests it is usually best to stop immediately when a failure occurs. 16 | continueAfterFailure = false 17 | 18 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | } 24 | 25 | func testExample() throws { 26 | // UI tests must launch the application that they test. 27 | let app = XCUIApplication() 28 | app.launch() 29 | 30 | // Use XCTAssert and related functions to verify your tests produce the correct results. 31 | } 32 | 33 | func testLaunchPerformance() throws { 34 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 35 | // This measures how long it takes to launch your application. 36 | measure(metrics: [XCTApplicationLaunchMetric()]) { 37 | XCUIApplication().launch() 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/StagedView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StagedView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/07. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct StagedView: View { 11 | @Binding var fileDiffs: [ExpandableModel] 12 | var status: String 13 | var onSelectFileDiff: ((FileDiff) -> Void)? 14 | var onSelectChunk: ((FileDiff, Chunk) -> Void)? 15 | @State private var isExpanded = true 16 | 17 | var body: some View { 18 | DisclosureGroup(isExpanded: $isExpanded) { 19 | if fileDiffs.isEmpty { 20 | LazyVStack(alignment: .center) { 21 | Label("No Changes", systemImage: "plusminus") 22 | .foregroundStyle(.secondary) 23 | .padding() 24 | .padding() 25 | .padding(.trailing) 26 | } 27 | } else { 28 | StagedFileDiffView( 29 | expandableFileDiffs: $fileDiffs, 30 | selectButtonImageSystemName: "minus.circle", 31 | selectButtonHelp: "Unstage This Hunk", 32 | onSelectFileDiff: onSelectFileDiff, 33 | onSelectChunk: onSelectChunk 34 | ) 35 | .padding(.leading, 4) 36 | .padding(.top) 37 | } 38 | } label: { 39 | SectionHeader(title: "Staged Changes", callout: status) 40 | .padding(.leading, 3) 41 | } 42 | .padding(.horizontal) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/StageFileDiffsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StagedFileDiffsView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/03/15. 6 | // 7 | import SwiftUI 8 | 9 | struct StagedFileDiffView: View { 10 | @Binding var expandableFileDiffs: [ExpandableModel] 11 | var selectButtonImageSystemName: String 12 | var selectButtonHelp: String 13 | var onSelectFileDiff: ((FileDiff) -> Void)? 14 | var onSelectChunk: ((FileDiff, Chunk) -> Void)? 15 | @Environment(\.expandAllFiles) private var expandAllFilesID: UUID? 16 | @Environment(\.collapseAllFiles) private var collapseAllFilesID: UUID? 17 | 18 | var body: some View { 19 | LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) { 20 | ForEach($expandableFileDiffs, id: \.self) { $fileDiff in 21 | StageFileDiffView( 22 | expandableFileDiff: $fileDiff, 23 | selectButtonImageSystemName: selectButtonImageSystemName, 24 | selectButtonHelp: selectButtonHelp, 25 | onSelectFileDiff: onSelectFileDiff, 26 | onSelectChunk: onSelectChunk 27 | ) 28 | } 29 | .font(Font.system(.body, design: .monospaced)) 30 | } 31 | .onChange(of: expandAllFilesID) { _, _ in 32 | expandableFileDiffs = expandableFileDiffs.map { ExpandableModel(isExpanded: true, model: $0.model)} 33 | } 34 | .onChange(of: collapseAllFilesID) { _, _ in 35 | expandableFileDiffs = expandableFileDiffs.map { ExpandableModel(isExpanded: false, model: $0.model)} 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /GitClient/AppIcon.icon/icon.json: -------------------------------------------------------------------------------- 1 | { 2 | "fill" : { 3 | "automatic-gradient" : "extended-gray:1.00000,1.00000" 4 | }, 5 | "groups" : [ 6 | { 7 | "blur-material" : null, 8 | "hidden" : false, 9 | "layers" : [ 10 | { 11 | "blend-mode" : "normal", 12 | "fill" : { 13 | "automatic-gradient" : "extended-srgb:0.00000,0.53333,1.00000,1.00000" 14 | }, 15 | "glass" : true, 16 | "hidden" : false, 17 | "image-name" : "Oval.svg", 18 | "name" : "Oval", 19 | "opacity" : 0.85, 20 | "position" : { 21 | "scale" : 1, 22 | "translation-in-points" : [ 23 | 0, 24 | -97.828125 25 | ] 26 | } 27 | }, 28 | { 29 | "blend-mode" : "normal", 30 | "fill" : { 31 | "automatic-gradient" : "srgb:0.46202,0.83828,1.00000,1.00000" 32 | }, 33 | "glass" : true, 34 | "hidden" : false, 35 | "image-name" : "Oval2.svg", 36 | "name" : "Oval2", 37 | "opacity" : 0.85, 38 | "position" : { 39 | "scale" : 1, 40 | "translation-in-points" : [ 41 | -4.828125, 42 | 96.2 43 | ] 44 | } 45 | } 46 | ], 47 | "lighting" : "individual", 48 | "shadow" : { 49 | "kind" : "neutral", 50 | "opacity" : 0.5 51 | }, 52 | "specular" : true, 53 | "translucency" : { 54 | "enabled" : false, 55 | "value" : 0.1 56 | } 57 | } 58 | ], 59 | "supported-platforms" : { 60 | "circles" : [ 61 | "watchOS" 62 | ], 63 | "squares" : "shared" 64 | } 65 | } -------------------------------------------------------------------------------- /GitClient/Views/Commit/CommitMessageGenerationUnavailableView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommitMessageGenerationUnavailableView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/09/13. 6 | // 7 | 8 | import SwiftUI 9 | import FoundationModels 10 | 11 | struct CommitMessageGenerationUnavailableView: View { 12 | var reason: SystemLanguageModel.Availability.UnavailableReason 13 | @State private var modelNotReadyPopOver = false 14 | @State private var appleIntelligenceNotEnabled = false 15 | 16 | var body: some View { 17 | switch reason { 18 | case .modelNotReady: 19 | Button { 20 | modelNotReadyPopOver = true 21 | } label: { 22 | Image(systemName: "exclamationmark.circle") 23 | } 24 | .popover(isPresented: $modelNotReadyPopOver) { 25 | Text("The model(s) aren’t available on this device.\nModels are downloaded automatically based on factors like network status, battery level, and system load.") 26 | .padding() 27 | } 28 | case .appleIntelligenceNotEnabled: 29 | Button { 30 | appleIntelligenceNotEnabled = true 31 | } label: { 32 | Image(systemName: "exclamationmark.circle") 33 | } 34 | .popover(isPresented: $appleIntelligenceNotEnabled) { 35 | Text("Apple Intelligence is not enabled on this system.") 36 | .padding() 37 | } 38 | case .deviceNotEligible: 39 | // This device does not support Apple Intelligence. 40 | EmptyView() 41 | @unknown default: 42 | // The model is unavailable for unknown reasons. 43 | EmptyView() 44 | } 45 | } 46 | } 47 | 48 | #Preview { 49 | CommitMessageGenerationUnavailableView(reason: .modelNotReady) 50 | } 51 | -------------------------------------------------------------------------------- /GitClient/Views/ErrorTextSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorTextSheet.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/04/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ErrorTextSheet: View { 11 | @Binding var error: Error? 12 | 13 | var body: some View { 14 | VStack { 15 | Text("Error") 16 | .font(.headline) 17 | ScrollView { 18 | HStack(spacing: 0) { 19 | Text(error?.localizedDescription ?? "") 20 | .textSelection(.enabled) 21 | Spacer(minLength: 0) 22 | } 23 | } 24 | 25 | HStack { 26 | Spacer() 27 | Button("OK") { 28 | error = nil 29 | } 30 | } 31 | } 32 | .padding() 33 | .cornerRadius(8) 34 | .frame(width: 480, height: 240) 35 | .background(Color(NSColor.textBackgroundColor)) 36 | } 37 | } 38 | 39 | #Preview { 40 | @Previewable @State var error: Error? = ProcessError(description: "Hello") 41 | 42 | ErrorTextSheet(error: $error) 43 | } 44 | 45 | #Preview { 46 | @Previewable @State var error: Error? = ProcessError(description: """ 47 | Swift’s enumerations are well suited to represent simple errors. Create an enumeration that conforms to the Error protocol with a case for each possible error. If there are additional details about the error that could be helpful for recovery, use associated values to include that information. 48 | The following example shows an IntParsingError enumeration that captures two different kinds of errors that can occur when parsing an integer from a string: overflow, where the value represented by the string is too large for the integer data type, and invalid input, where nonnumeric characters are found within the input. 49 | """) 50 | 51 | ErrorTextSheet(error: $error) 52 | } 53 | -------------------------------------------------------------------------------- /GitClientTests/ExpandableModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExpandableModelTests.swift 3 | // GitClientTests 4 | // 5 | // Created by Makoto Aoyama on 2025/05/10. 6 | // 7 | 8 | import Testing 9 | @testable import Changes 10 | 11 | struct ExpandableModelTests { 12 | 13 | struct Dummy: Hashable { 14 | let id: Int 15 | let name: String 16 | } 17 | 18 | @Test 19 | func testPreservesExpansionState() { 20 | let old: [ExpandableModel] = [ 21 | ExpandableModel(isExpanded: false, model: Dummy(id: 1, name: "one")), 22 | ExpandableModel(isExpanded: false, model: Dummy(id: 2, name: "two")) 23 | ] 24 | let new: [Dummy] = [ 25 | Dummy(id: 1, name: "one"), 26 | Dummy(id: 3, name: "three") 27 | ] 28 | 29 | let result = new.withExpansionState(from: old) 30 | 31 | #expect(result.count == 2) 32 | #expect(result[0].model == Dummy(id: 1, name: "one")) 33 | #expect(result[0].isExpanded == false) 34 | #expect(result[1].model == Dummy(id: 3, name: "three")) 35 | #expect(result[1].isExpanded == true) // default 36 | } 37 | 38 | @Test 39 | func testAllNewModelsDefaultToExpanded() { 40 | let old: [ExpandableModel] = [] 41 | let new: [Dummy] = [ 42 | Dummy(id: 10, name: "ten"), 43 | Dummy(id: 20, name: "twenty") 44 | ] 45 | 46 | let result = new.withExpansionState(from: old) 47 | 48 | #expect(result.count == 2) 49 | #expect(result.allSatisfy { $0.isExpanded }) 50 | } 51 | 52 | @Test 53 | func testEmptyUpdateReturnsEmpty() { 54 | let old: [ExpandableModel] = [ 55 | ExpandableModel(isExpanded: true, model: Dummy(id: 1, name: "one")) 56 | ] 57 | let new: [Dummy] = [] 58 | 59 | let result = new.withExpansionState(from: old) 60 | #expect(result.isEmpty) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/CommitMessageSnippetSuggestionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageSnippetSuggestionView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/08/10. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CommitMessageSnippetSuggestionView: View { 11 | @State private var error: Error? 12 | @State private var isPresenting = false 13 | @Environment(\.openWindow) private var openWindow 14 | @AppStorage(AppStorageKey.commitMessageSnippet.rawValue) var commitMessageSnippet: Data = AppStorageDefaults.commitMessageSnippets 15 | var decodedCommitMessageSnippet: Array { 16 | do { 17 | do { 18 | return try JSONDecoder().decode(Array.self, from: commitMessageSnippet) 19 | } catch { 20 | return [] 21 | } 22 | } 23 | } 24 | 25 | var body: some View { 26 | HStack { 27 | ScrollView(.horizontal) { 28 | LazyHStack { 29 | ForEach(decodedCommitMessageSnippet, id: \.self) { snippet in 30 | Button(snippet) { 31 | NotificationCenter.default.post(name: .didSelectCommitMessageSnippetNotification, object: snippet) 32 | } 33 | .buttonStyle(.plain) 34 | if snippet != decodedCommitMessageSnippet.last { 35 | Text("|") 36 | .foregroundStyle(.separator) 37 | } 38 | } 39 | } 40 | .padding(.leading) 41 | } 42 | .frame(height: 40) 43 | Button(action: { 44 | openWindow(id: WindowID.commitMessageSnippets.rawValue) 45 | }, label: { 46 | Image(systemName: "list.dash") 47 | }) 48 | } 49 | .errorSheet($error) 50 | } 51 | } 52 | 53 | #Preview { 54 | CommitMessageSnippetSuggestionView() 55 | } 56 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/DiffCommitListContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DiffCommitListContentView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/05/18. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DiffCommitListContentView: View { 11 | var commits: [Commit] 12 | 13 | var body: some View { 14 | LazyVStack(spacing: 4) { 15 | ForEach(commits) { commit in 16 | NavigationLink(value: commit.hash) { 17 | VStack (alignment: .leading, spacing: 6) { 18 | HStack(alignment: .firstTextBaseline) { 19 | VStack(alignment: .leading, spacing: 6) { 20 | Text(commit.title) 21 | if !commit.body.isEmpty { 22 | Text(commit.body.trimmingCharacters(in: .whitespacesAndNewlines)) 23 | .foregroundStyle(.secondary) 24 | .font(.callout) 25 | } 26 | } 27 | Spacer() 28 | Text(commit.hash.prefix(5)) 29 | .foregroundStyle(.tertiary) 30 | } 31 | HStack { 32 | Icon(size: .small, authorEmail: commit.authorEmail, authorInitial: String(commit.author.initial.prefix(1))) 33 | Text(commit.author) 34 | Spacer() 35 | Text(commit.authorDateRelative) 36 | } 37 | .lineLimit(1) 38 | .foregroundStyle(.tertiary) 39 | } 40 | } 41 | .buttonStyle(.plain) 42 | if commits.last != commit { 43 | Divider() 44 | } 45 | } 46 | .accentColor(.primary) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitShow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitShow.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/10/08. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitShow: Git { 11 | typealias OutputModel = CommitDetail 12 | var arguments: [String] { 13 | [ 14 | "git", 15 | "show", 16 | "--pretty=format:%H" 17 | + .formatSeparator + "%P" 18 | + .formatSeparator + "%an" 19 | + .formatSeparator + "%aE" 20 | + .formatSeparator + "%aI" 21 | + .formatSeparator + "%s" 22 | + .formatSeparator + "%b" 23 | + .formatSeparator + "%D" 24 | + .componentSeparator, 25 | object 26 | ] 27 | } 28 | var directory: URL 29 | var object: String 30 | 31 | func parse(for stdOut: String) throws -> CommitDetail { 32 | guard !stdOut.isEmpty else { throw GenericError(errorDescription: "Parse error: stdOut is empty.") } 33 | let splits = stdOut.split(separator: String.componentSeparator + "\n", maxSplits: 1) 34 | let commitInfo = splits[0] 35 | let separated = commitInfo.components(separatedBy: String.formatSeparator) 36 | let refs: [String] 37 | if separated[7].isEmpty { 38 | refs = [] 39 | } else { 40 | refs = separated[7].components(separatedBy: ", ") 41 | } 42 | let commit = Commit( 43 | hash: separated[0], 44 | parentHashes: separated[1].components(separatedBy: .whitespacesAndNewlines), 45 | author: separated[2], 46 | authorEmail: separated[3], 47 | authorDate: separated[4], 48 | title: separated[5], 49 | body: separated[6], 50 | branches: refs.filter { !$0.hasPrefix("tag: ") }, 51 | tags: refs.filter { $0.hasPrefix("tag: ") }.map { String($0.dropFirst(5)) } 52 | ) 53 | 54 | return CommitDetail( 55 | commit: commit, 56 | diff: try Diff(raw: String(splits[safe: 1] ?? "")) 57 | ) 58 | } 59 | } 60 | 61 | -------------------------------------------------------------------------------- /GitClient/Assets.xcassets/Swift.imageset/Swift_logo_color.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 16 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/CommitDetailBottomBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommitDetailBottomBar.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/09/18. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CommitDetailBottomBar: View { 11 | var commit: Commit 12 | var folder: Folder 13 | @Binding var fileDiffs: [ExpandableModel] 14 | @State private var shortstat = "" 15 | 16 | var body: some View { 17 | VStack(spacing: 0) { 18 | Spacer() 19 | HStack { 20 | Spacer() 21 | Text(shortstat) 22 | .minimumScaleFactor(0.3) 23 | .foregroundStyle(.primary) 24 | Spacer() 25 | } 26 | .font(.callout) 27 | Spacer() 28 | } 29 | .frame(height: 40) 30 | .overlay(alignment: .leading) { 31 | if !fileDiffs.isEmpty { 32 | HStack { 33 | Button { 34 | fileDiffs = fileDiffs.map { 35 | ExpandableModel(isExpanded: true, model: $0.model) 36 | } 37 | } label: { 38 | Image(systemName: "arrow.up.and.line.horizontal.and.arrow.down") 39 | } 40 | .help("Expand All Files") 41 | Button { 42 | fileDiffs = fileDiffs.map { 43 | ExpandableModel(isExpanded: false, model: $0.model) 44 | } 45 | } label: { 46 | Image(systemName: "arrow.down.and.line.horizontal.and.arrow.up") 47 | } 48 | .help("Collapse All Files") 49 | } 50 | .padding() 51 | .buttonStyle(.plain) 52 | } 53 | } 54 | .onChange(of: commit, initial: true, { _, commit in 55 | Task { 56 | shortstat = (try? await Process.output(GitShowShortstat(directory: folder.url, object: commit.hash))) ?? "" 57 | } 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /GitClient/Views/Folder/Icon.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IconView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/05/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct Icon: View { 11 | enum Size { 12 | case small, medium 13 | var image: CGFloat { 14 | switch self { 15 | case .small: 16 | return 14 17 | case .medium: 18 | return 26 19 | } 20 | } 21 | var corner: CGSize { 22 | switch self { 23 | case .small: 24 | return .init(width: 3, height: 3) 25 | case .medium: 26 | return .init(width: 6, height: 6) 27 | } 28 | } 29 | var font: CGFloat { 30 | switch self { 31 | case .small: 32 | return 8 33 | case .medium: 34 | return 12 35 | } 36 | } 37 | } 38 | @Environment(\.openURL) private var openURL 39 | var size: Size 40 | var authorEmail: String 41 | var authorInitial: String 42 | 43 | var body: some View { 44 | AsyncImage(url: URL.gravater(email: authorEmail, size: size.image*3)) { phase in 45 | if let image = phase.image { 46 | image.resizable() 47 | .onTapGesture { 48 | guard let url = URL.gravater(email: authorEmail, size: 400) else { return } 49 | openURL(url) 50 | } 51 | 52 | } else if phase.error != nil { 53 | AuthorInitialIcon(initial: authorInitial) 54 | .font(.system(size: size.font, weight: .medium)) 55 | } else { 56 | RoundedRectangle(cornerSize: size.corner, style: .circular) 57 | .foregroundStyle(.quinary) 58 | } 59 | } 60 | .frame(width: size.image, height: size.image) 61 | .clipShape(RoundedRectangle(cornerSize: size.corner, style: .circular)) 62 | } 63 | } 64 | 65 | #Preview { 66 | Icon(size: .small, authorEmail: "", authorInitial: "makoto aoyama".initial) 67 | } 68 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/MergeCommitContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MergeCommitContentView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/10/13. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MergeCommitContentView: View { 11 | var mergeCommit: Commit 12 | var directoryURL: URL 13 | @Binding var tab: Int 14 | @Binding var filesChanged: [ExpandableModel] 15 | @State private var commits: [Commit] = [] 16 | @State private var error: Error? 17 | 18 | var body: some View { 19 | DiffView( 20 | commits: $commits, 21 | filesChanged: $filesChanged, 22 | tab: $tab 23 | ) 24 | .onChange(of: mergeCommit, initial: true) { 25 | Task { 26 | do { 27 | commits = try await Array(Process.output(GitLog(directory: directoryURL, revisionRange: ["\(mergeCommit.parentHashes[0])..\(mergeCommit.hash)"])).dropFirst()) 28 | let diffRaw = try await Process.output( 29 | GitDiff(directory: directoryURL, noRenames: false, commitRange: mergeCommit.parentHashes[0] + ".." + mergeCommit.hash) 30 | ) 31 | filesChanged = try Diff(raw: diffRaw).fileDiffs.map { .init(isExpanded: true, model: $0) } 32 | } catch { 33 | self.error = error 34 | } 35 | } 36 | } 37 | .errorSheet($error) 38 | } 39 | } 40 | 41 | #Preview { 42 | ScrollView { 43 | MergeCommitContentView( 44 | mergeCommit: .init( 45 | hash: "11fff", 46 | parentHashes: ["21fff", "31fff"], 47 | author: "maoyama", 48 | authorEmail: "a@aoyama.dev", 49 | authorDate: "2014-10-10T13:50:40+09:00", 50 | title: "Hello world!", 51 | body: "body", 52 | branches: [], 53 | tags: [] 54 | ), 55 | directoryURL: URL(string: "file:///maoyama/Projects/")!, 56 | tab: .constant(0), 57 | filesChanged: .constant([]) 58 | ) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /GitClient/Views/Folder/Sheets/AmendCommitSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Untitled.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/04/09. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AmendCommitSheet: View { 11 | var folder: Folder 12 | @Binding var showingAmendCommitAt: Commit? 13 | var onCreate: (() -> Void) 14 | @State private var message = "" 15 | @State private var error: Error? 16 | 17 | var body: some View { 18 | VStack { 19 | Text("Amend Commit with a New Message") 20 | .font(.headline) 21 | VStack(alignment: .leading) { 22 | HStack { 23 | TextEditor(text: $message) 24 | .scrollContentBackground(.hidden) 25 | .frame(height: 100) 26 | // .contentMargins(8, for: .scrollContent) 27 | // `contentMargins` not work on macOS. Xcode26.0 and macOS26 28 | } 29 | HStack { 30 | Button("Cancel") { 31 | showingAmendCommitAt = nil 32 | } 33 | Spacer() 34 | Button("Amend") { 35 | Task { 36 | do { 37 | try await Process.output( 38 | GitCommitAmend(directory: folder.url, message: message) 39 | ) 40 | onCreate() 41 | showingAmendCommitAt = nil 42 | } catch { 43 | self.error = error 44 | } 45 | } 46 | } 47 | .disabled(message.isEmpty) 48 | .buttonStyle(.borderedProminent) 49 | .keyboardShortcut(.init(.return)) 50 | } 51 | .padding(.top) 52 | } 53 | .frame(width: 400) 54 | .padding() 55 | } 56 | .padding() 57 | .cornerRadius(8) 58 | .task { 59 | message = showingAmendCommitAt?.rawBody ?? "" 60 | } 61 | .errorSheet($error) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /GitClient/Models/Language.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Language.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/02/17. 6 | // 7 | 8 | import Foundation 9 | import Sourceful 10 | import SwiftUI 11 | 12 | enum Language: String { 13 | case swift, python, javascript, typescript, java, kotlin, c, cpp, csharp, ruby, php, go, rust, shell, perl, html, css, markdown, ocaml 14 | 15 | private static func detect(filePath: String) -> Language? { 16 | let ext = URL(fileURLWithPath: String(filePath)).pathExtension.lowercased() 17 | 18 | switch ext { 19 | case "swift": return .swift 20 | case "py": return .python 21 | case "js": return .javascript 22 | case "ts": return .typescript 23 | case "java": return .java 24 | case "kt": return .kotlin 25 | case "c": return .c 26 | case "cpp", "cc", "cxx": return .cpp 27 | case "cs": return .csharp 28 | case "rb": return .ruby 29 | case "php": return .php 30 | case "go": return .go 31 | case "rs": return .rust 32 | case "sh", "bash", "zsh": return .shell 33 | case "pl": return .perl 34 | case "html": return .html 35 | case "css": return .css 36 | case "md": return .markdown 37 | case "ml": return .ocaml 38 | default: return nil 39 | } 40 | } 41 | 42 | static func lexer(filePath: String) -> Lexer { 43 | switch detect(filePath: filePath) { 44 | case .java: 45 | return JavaLexer() 46 | case .javascript, .typescript: 47 | return JavaScriptLexer() 48 | case .python: 49 | return Python3Lexer() 50 | case .swift: 51 | return SwiftLexer() 52 | case .ocaml: 53 | return OCamlLexer() 54 | default: 55 | return PlainLexer() 56 | } 57 | } 58 | 59 | static func assetName(filePath: String) -> String? { 60 | switch detect(filePath: filePath) { 61 | case .swift: 62 | return "Swift" 63 | case .python: 64 | return "python" 65 | case .ruby: 66 | return "ruby" 67 | case .rust: 68 | return "rust" 69 | case .javascript: 70 | return "js" 71 | case .ocaml: 72 | return "ocaml" 73 | default: 74 | return nil 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /GitClient/Views/Folder/Sheets/RenameBranchSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RenameBranchSheet.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/04/08. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct RenameBranchSheet: View { 11 | var folder: Folder 12 | @Binding var showingRenameBranch: Branch? 13 | @State private var newBranchName = "" 14 | @State private var error: Error? 15 | 16 | var body: some View { 17 | VStack { 18 | Text("Rename Branch") 19 | .font(.headline) 20 | VStack(alignment: .leading) { 21 | HStack { 22 | VStack(alignment: .trailing) { 23 | Text("Old:") 24 | Text("New:") 25 | .padding(.vertical, 2) 26 | } 27 | VStack(alignment: .leading) { 28 | Text(showingRenameBranch?.name ?? "") 29 | .textSelection(.enabled) 30 | .padding(.horizontal, 4) 31 | TextField("New branch name", text: $newBranchName) 32 | } 33 | } 34 | 35 | HStack { 36 | Button("Cancel") { 37 | showingRenameBranch = nil 38 | } 39 | Spacer() 40 | Button("Rename") { 41 | Task { 42 | do { 43 | try await Process.output( 44 | GitBranchRename(directory: folder.url, oldBranchName: showingRenameBranch?.name ?? "", newBranchName: newBranchName) 45 | ) 46 | showingRenameBranch = nil 47 | } catch { 48 | self.error = error 49 | } 50 | } 51 | } 52 | .buttonStyle(.borderedProminent) 53 | .keyboardShortcut(.init(.defaultAction)) 54 | .disabled(newBranchName.isEmpty) 55 | } 56 | .padding(.top) 57 | } 58 | .frame(width: 400) 59 | .padding() 60 | } 61 | .padding() 62 | .cornerRadius(8) 63 | .errorSheet($error) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /GitClient/Assets.xcassets/js.imageset/js.svg: -------------------------------------------------------------------------------- 1 | 2 | 26 | 31 | 32 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/FileNameView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileNameView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/04/06. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FileNameView: View { 11 | @Environment(\.folder) private var current 12 | 13 | var toFilePath: String 14 | var filePathDisplay: String 15 | var fileURL: URL? { 16 | current?.appending(path: toFilePath) 17 | } 18 | 19 | var body: some View { 20 | HStack { 21 | if let asset = Language.assetName(filePath: toFilePath) { 22 | Image(asset) 23 | .resizable() 24 | .scaledToFit() 25 | .frame(width: 18) 26 | } else { 27 | Image(systemName: "doc") 28 | .frame(width: 18, height: 18) 29 | .fontWeight(.heavy) 30 | } 31 | Text(filePathDisplay) 32 | .fontWeight(.bold) 33 | .font(Font.system(.body, design: .default)) 34 | Button(action: { 35 | NSWorkspace.shared.open(fileURL!) 36 | }) { 37 | Image(systemName: "arrow.right.circle.fill") 38 | .foregroundStyle(.secondary) 39 | .help("Open " + (fileURL?.absoluteString ?? "")) 40 | } 41 | .buttonStyle(.plain) 42 | Spacer() 43 | } 44 | } 45 | } 46 | 47 | #Preview { 48 | 49 | FileNameView( 50 | toFilePath: "Sources/MyFeature/File.swift", 51 | filePathDisplay: "Sources/MyFeature/File.swift" 52 | ) 53 | FileNameView( 54 | toFilePath: "Sources/MyFeature/File.py", 55 | filePathDisplay: "Sources/MyFeature/File.py" 56 | ) 57 | FileNameView( 58 | toFilePath: "Sources/MyFeature/File.rb", 59 | filePathDisplay: "Sources/MyFeature/File.rb" 60 | ) 61 | FileNameView( 62 | toFilePath: "Sources/MyFeature/File.rs", 63 | filePathDisplay: "Sources/MyFeature/File.rs" 64 | ) 65 | 66 | FileNameView( 67 | toFilePath: "Sources/MyFeature/File.js", 68 | filePathDisplay: "Sources/MyFeature/File.js" 69 | ) 70 | 71 | FileNameView( 72 | toFilePath: "Sources/MyFeature/File.ml", 73 | filePathDisplay: "Sources/MyFeature/File.ml" 74 | ) 75 | FileNameView( 76 | toFilePath: "Sources/MyFeature/File.pbj", 77 | filePathDisplay: "Sources/MyFeature/File.pbj" 78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /GitClient/Models/SearchTokensHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SerachTokensHandler.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/04/03. 6 | // 7 | 8 | struct SearchTokensHandler { 9 | static private func newToken(old: [SearchToken], new: [SearchToken]) -> SearchToken? { 10 | new.first { !old.contains($0) } 11 | } 12 | 13 | static func searchTokenHistory(currentHistory: [SearchToken], old: [SearchToken], new: [SearchToken]) -> [SearchToken] { 14 | guard let newToken = SearchTokensHandler.newToken(old: old, new: new) else { return currentHistory } 15 | var history = currentHistory 16 | history.removeAll { $0 == newToken } 17 | history.insert(newToken, at: 0) 18 | return Array(history.prefix(10)) 19 | } 20 | 21 | static func normalize(oldTokens: [SearchToken], newTokens: [SearchToken]) -> [SearchToken] { 22 | if let newToken = newToken(old: oldTokens, new: newTokens) { 23 | switch newToken.kind { 24 | case .grep, .grepAllMatch: 25 | return newTokens.map { token in 26 | switch token.kind { 27 | case .grep, .grepAllMatch: 28 | var updateToken = token 29 | updateToken.kind = newToken.kind 30 | return updateToken 31 | default: 32 | return token 33 | } 34 | } 35 | case .s: 36 | return newTokens.filter { token in 37 | switch token.kind { 38 | case .s: 39 | return token == newToken 40 | case .g: 41 | return false 42 | default: 43 | return true 44 | } 45 | } 46 | case .g: 47 | return newTokens.filter { token in 48 | switch token.kind { 49 | case .g: 50 | return token == newToken 51 | case .s: 52 | return false 53 | default: 54 | return true 55 | } 56 | } 57 | case .revisionRange, .author, .path: 58 | return newTokens 59 | // case .ai: 60 | // return newTokens 61 | } 62 | } else { 63 | return newTokens 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /GitClient/Models/SearchKind.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchKind.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/04/02. 6 | // 7 | 8 | import Foundation 9 | 10 | enum SearchKind: Codable, CaseIterable { 11 | case grep, grepAllMatch, g, s, author, revisionRange, path //, ai 12 | 13 | var label: String { 14 | switch self { 15 | case .grep: 16 | return "Message" 17 | case .grepAllMatch: 18 | return "Message(All Match)" 19 | case .g: 20 | return "Changed" 21 | case .s: 22 | return "Changed(Occurrences)" 23 | case .author: 24 | return "Author" 25 | case .revisionRange: 26 | return "Revision Range" 27 | case .path: 28 | return "Path" 29 | // case .ai: 30 | // return "AI" 31 | } 32 | } 33 | 34 | var shortLabel: String { 35 | switch self { 36 | case .grep: 37 | return "Message" 38 | case .grepAllMatch: 39 | return "Message(A)" 40 | case .g: 41 | return "Changed" 42 | case .s: 43 | return "Changed(O)" 44 | case .author: 45 | return "Author" 46 | case .revisionRange: 47 | return "Revision Range" 48 | case .path: 49 | return "Path" 50 | // case .ai: 51 | // return "AI" 52 | } 53 | } 54 | 55 | var help: String { 56 | switch self { 57 | case .grep: 58 | return "Search log messages matching the given pattern (regular expression)." 59 | case .grepAllMatch: 60 | return "Search log messages matching all given patterns instead of at least one." 61 | case .g: 62 | return "Search commits with added/removed lines that match the specified regex. " 63 | case .s: 64 | return "Search commits where the number of occurrences of the specified regex has changed (added/removed)." 65 | case .author: 66 | return "Search commits by author matching the given pattern (regular expression)." 67 | case .revisionRange: 68 | return "Search commits within the revision range specified by Git syntax. e.g., main.., v1.0.0...v2.0.0" 69 | case .path: 70 | return "Search commits that modify the specified file or directory path." 71 | // case .ai: 72 | // return "Search commits based on natural language input, powered by AI." 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/ChunkView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChunkView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/02/18. 6 | // 7 | 8 | 9 | import SwiftUI 10 | import Sourceful 11 | 12 | struct ChunkView: View { 13 | var chunk: Chunk 14 | var filePath: String 15 | var lexer: Lexer { 16 | get { 17 | Language.lexer(filePath: filePath) 18 | } 19 | } 20 | 21 | var body: some View { 22 | SourceCodeTextEditor( 23 | text: .constant(chunk.raw), 24 | customization: .init( 25 | didChangeText: {_ in }, 26 | insertionPointColor: { Sourceful.Color.white }, 27 | lexerForSource: { _ in lexer }, 28 | textViewDidBeginEditing: { _ in }, 29 | theme: { FileDiffTheme() } 30 | ), 31 | lineNumbers: .constant(chunk.lineNumbers) 32 | ) 33 | } 34 | } 35 | 36 | #Preview { 37 | let filePath = "GitClient/Models/Language.swift" 38 | let text = """ 39 | @@ -127,9 +127,6 @@ public struct SourceCodeTextEditor: _ViewRepresentable { 40 | // Comment 41 | public func sizeThatFits(_ proposal: ProposedViewSize, nsView: SyntaxTextView, context: Context) -> CGSize? { 42 | guard let width = proposal.width else { return nil } 43 | let height = fittingHeight(for: nsView.contentTextView, width: width) 44 | + print("gutterWidth", nsView.textView.gutterWidth) 45 | - print("Computed Size:", CGSize(width: width, height: height)) 46 | - 47 | return CGSize(width: width, height: height) 48 | } 49 | """ 50 | 51 | let text2 = """ 52 | @@ -12,9 +12,6 @@ public struct SourceCodeTextEditor: _ViewRepresentable { 53 | // Comment 54 | public func sizeThatFits(_ proposal: ProposedViewSize, nsView: SyntaxTextView, context: Context) -> CGSize? { 55 | guard let width = proposal.width else { return nil } 56 | let height = fittingHeight(for: nsView.contentTextView, width: width) 57 | + print("gutterWidth", nsView.textView.gutterWidth) 58 | - print("Computed Size:", CGSize(width: width, height: height)) 59 | - 60 | return CGSize(width: width, height: height) 61 | } 62 | """ 63 | 64 | ScrollView { 65 | LazyVStack { 66 | ChunkView(chunk: Chunk(raw: text), filePath: filePath) 67 | ChunkView(chunk: Chunk(raw: text2), filePath: filePath) 68 | ChunkView(chunk: Chunk(raw: text), filePath: filePath) 69 | } 70 | .frame(width: 400) 71 | } 72 | } 73 | 74 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/DiffCommitListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DiffCommitListView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/05/18. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DiffCommitListView: View { 11 | var commits: [Commit] 12 | private var authorEmailAndNames: [(String, String)] { 13 | commits.map { ($0.authorEmail, $0.author) } 14 | .reduce(into: []) { result, item in 15 | if !result.contains(where: { $0.0 == item.0 }) { 16 | result.append(item) 17 | } 18 | } 19 | } 20 | 21 | var body: some View { 22 | HStack(alignment: .top, spacing: 24) { 23 | DiffCommitListContentView(commits: commits) 24 | HStack(spacing: 0) { 25 | VStack(alignment: .leading, spacing: 6) { 26 | if commits.count > 1 { 27 | Text("\(commits.count) commits") 28 | Text(commits.first!.authorDateDisplayShort) 29 | Image(systemName: "minus") 30 | .rotationEffect(.init(degrees: 90)) 31 | .foregroundStyle(.tertiary) 32 | Text(commits.last!.authorDateDisplayShort) 33 | LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]) { 34 | ForEach(authorEmailAndNames, id: \.0) { element in 35 | Icon(size: .medium, authorEmail: element.0, authorInitial: String(element.1.initial.prefix(2))) 36 | .help(element.1) 37 | } 38 | } 39 | .padding(.vertical, 8) 40 | } else { 41 | EmptyView() 42 | } 43 | } 44 | Spacer(minLength: 0) 45 | } 46 | .frame(width: 120) 47 | } 48 | } 49 | } 50 | 51 | #Preview { 52 | DiffCommitListView(commits: [ 53 | Commit(hash: "a", parentHashes: ["b"], author: "m a", authorEmail: "dummy@aoyama.dev", authorDate: "", title: "Hi", body: "", branches: [], tags: []), 54 | Commit(hash: "b", parentHashes: ["c"], author: "m b", authorEmail: "dummy2@aoyama.dev", authorDate: "", title: "Hi", body: "", branches: [], tags: []), 55 | Commit(hash: "d", parentHashes: ["e"], author: "m a", authorEmail: "dummy@aoyama.dev", authorDate: "", title: "Hi", body: "", branches: [], tags: []) 56 | ]) 57 | } 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | 92 | # Bundler 93 | /.bundle 94 | vendor/bundle 95 | 96 | .DS_Store 97 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/FileDiffTheme.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileDiffTheme.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/02/16. 6 | // 7 | 8 | 9 | import Foundation 10 | import Sourceful 11 | import AppKit 12 | 13 | 14 | public struct FileDiffTheme: SourceCodeTheme { 15 | 16 | public init() {} 17 | 18 | public static var font: NSFont { 19 | let baseFont = NSFont.preferredFont(forTextStyle: .body) 20 | return NSFont.monospacedSystemFont(ofSize: baseFont.pointSize - 1, weight: .regular) 21 | } 22 | 23 | private static var lineNumbersColor: Color { 24 | return NSColor.tertiaryLabelColor 25 | } 26 | 27 | public let lineNumbersStyle: LineNumbersStyle? = LineNumbersStyle(font: font, textColor: lineNumbersColor) 28 | 29 | public let gutterStyle: GutterStyle = GutterStyle(backgroundColor: NSColor.textBackgroundColor, minimumWidth: 42) // コードのインデントをラインナンバーが3桁と2桁を揃えるため、minimumWidth = 42に設定 30 | 31 | public var font: NSFont { 32 | FileDiffTheme.font 33 | } 34 | 35 | public let backgroundColor = NSColor.textBackgroundColor 36 | 37 | public func color(for syntaxColorType: SourceCodeTokenType) -> Color { 38 | switch syntaxColorType { 39 | case .plain: 40 | return NSColor.textColor 41 | 42 | case .number: 43 | return Color(red: 116/255, green: 109/255, blue: 176/255, alpha: 1.0) 44 | 45 | case .string: 46 | return Color(red: 211/255, green: 35/255, blue: 46/255, alpha: 1.0) 47 | 48 | case .identifier: 49 | return Color(red: 20/255, green: 156/255, blue: 146/255, alpha: 1.0) 50 | 51 | case .keyword: 52 | return Color(red: 215/255, green: 0, blue: 143/255, alpha: 1.0) 53 | 54 | case .comment: 55 | return Color(red: 69.0/255.0, green: 187.0/255.0, blue: 62.0/255.0, alpha: 1.0) 56 | 57 | case .editorPlaceholder: 58 | return backgroundColor 59 | } 60 | } 61 | 62 | public func color(for diffType: GitDiffOutputChunkTokenType) -> Color? { 63 | switch diffType { 64 | case .header: 65 | return NSColor.tertiaryLabelColor 66 | default: 67 | return nil 68 | } 69 | } 70 | 71 | public func backGroundColor(for diffType: GitDiffOutputChunkTokenType) -> Color? { 72 | switch diffType { 73 | case .header, .unchanged: 74 | return nil 75 | case .removed: 76 | return NSColor.red.withAlphaComponent(0.1) 77 | case .added: 78 | return NSColor.green.withAlphaComponent(0.2) 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/StageFileDiffView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StageFileDiffView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/03/16. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct StageFileDiffView: View { 11 | @Binding var expandableFileDiff: ExpandableModel 12 | var fileDiff: FileDiff { 13 | expandableFileDiff.model 14 | } 15 | var selectButtonImageSystemName: String 16 | var selectButtonHelp: String 17 | var onSelectFileDiff: ((FileDiff) -> Void)? 18 | var onSelectChunk: ((FileDiff, Chunk) -> Void)? 19 | 20 | var body: some View { 21 | DisclosureGroup(isExpanded: $expandableFileDiff.isExpanded) { 22 | LazyVStack(alignment: .leading, spacing: 8) { 23 | ForEach(fileDiff.chunks) { chunk in 24 | HStack(spacing: 0) { 25 | ChunkView(chunk: chunk, filePath: fileDiff.toFilePath) 26 | Spacer(minLength: 0) 27 | Button { 28 | onSelectChunk?(fileDiff, chunk) 29 | } label: { 30 | Image(systemName: selectButtonImageSystemName) 31 | .frame(width: 20, height: 20) 32 | } 33 | .buttonStyle(.plain) 34 | .help(selectButtonHelp) 35 | .padding(.vertical) 36 | .disabled(onSelectChunk == nil) 37 | } 38 | } 39 | } 40 | .padding(.top, 8) 41 | .padding(.bottom, 12) 42 | if fileDiff.chunks.isEmpty { 43 | HStack { 44 | VStack(alignment: .leading) { 45 | Text(fileDiff.header) 46 | Text(fileDiff.extendedHeaderLines.joined(separator: "\n")) 47 | Text(fileDiff.fromFileToFileLines.joined(separator: "\n")) 48 | } 49 | Spacer() 50 | Button { 51 | onSelectFileDiff?(fileDiff) 52 | } label: { 53 | Image(systemName: selectButtonImageSystemName) 54 | .frame(width: 20, height: 20) 55 | } 56 | .buttonStyle(.plain) 57 | .help(selectButtonHelp) 58 | .padding() 59 | } 60 | } 61 | } label: { 62 | StageFileDiffHeaderView(fileDiff: fileDiff) 63 | .padding(.leading, 3) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /GitClient/Models/CommitGraph.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommitGraph.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/06/14. 6 | // 7 | 8 | 9 | struct CommitGraph { 10 | private func makeColumn(childColumn: Int, usingColumn: [Int]) -> Int { 11 | var col = childColumn + 1 12 | while usingColumn.contains(col) { 13 | col += 1 14 | } 15 | return col 16 | } 17 | 18 | func positionedCommits(_ commits: [Commit]) -> [PositionedCommit] { 19 | var result: [PositionedCommit] = [] 20 | var usingColumns: [Int] = [] 21 | 22 | for (row, commit) in commits.enumerated() { 23 | if row == 0 { 24 | // 最初のカラムは0 25 | result.append(PositionedCommit(commit: commit, column: 0, row: row)) 26 | usingColumns.append(0) 27 | } else { 28 | let children = result.filter { $0.commit.parentHashes.contains { $0 == commit.hash } } 29 | if children.isEmpty { // 検索条件で子のコミットがない時 30 | let positioned = PositionedCommit(commit: commit, column: result[row - 1].column, row: row, childrenIsHidden: true) 31 | result.append(positioned) 32 | } else { 33 | let positioned: PositionedCommit 34 | if let childColumn = children.filter({ $0.commit.parentHashes[0] == commit.hash }).map({ $0.column }).min() { 35 | // 子のカラムを受け継ぐ。新しいカラムを必要としない場合 36 | positioned = PositionedCommit(commit: commit, column: childColumn, row: row) 37 | } else { 38 | let newColumn = makeColumn(childColumn: children[0].column, usingColumn: usingColumns) 39 | positioned = PositionedCommit(commit: commit, column: newColumn, row: row) 40 | usingColumns.append(newColumn) 41 | } 42 | result.append(positioned) 43 | children.forEach { child in 44 | // parentHashesが1でない時はマージコミットであり、別の親がカラムをまだ利用するため 45 | if child.column != positioned.column && child.commit.parentHashes.count == 1 { 46 | if let index = usingColumns.firstIndex(where: { $0 == child.column }) { 47 | usingColumns.remove(at: index) 48 | } 49 | } 50 | } 51 | } 52 | } 53 | } 54 | 55 | return result 56 | } 57 | } 58 | 59 | struct PositionedCommit: Identifiable { 60 | var id: String { commit.hash } 61 | let commit: Commit 62 | let column: Int 63 | let row: Int 64 | var childrenIsHidden: Bool = false 65 | } 66 | 67 | -------------------------------------------------------------------------------- /GitClient/Views/Folder/Sheets/CreateNewBranchSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateNewBranchSheet.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2022/10/08. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CreateNewBranchSheet: View { 11 | var folder: Folder 12 | @Binding var showingCreateNewBranchFrom: Branch? 13 | var onCreate: (() -> Void) 14 | @State private var newBranchName = "" 15 | @State private var error: Error? 16 | 17 | var body: some View { 18 | VStack { 19 | Text("Create New Branch") 20 | .font(.headline) 21 | VStack(alignment: .leading) { 22 | HStack { 23 | VStack(alignment: .trailing) { 24 | Text("from:") 25 | Text("to:") 26 | .padding(.vertical, 2) 27 | } 28 | VStack(alignment: .leading) { 29 | Text(showingCreateNewBranchFrom?.name ?? "") 30 | .textSelection(.enabled) 31 | .padding(.horizontal, 4) 32 | TextField("New branch name", text: $newBranchName) 33 | } 34 | } 35 | HStack { 36 | Button("Cancel") { 37 | showingCreateNewBranchFrom = nil 38 | } 39 | Spacer() 40 | Button("Create") { 41 | Task { 42 | do { 43 | try await Process.output( 44 | GitCheckoutB(directory: folder.url, newBranchName: newBranchName, startPoint: showingCreateNewBranchFrom!.point) 45 | ) 46 | showingCreateNewBranchFrom = nil 47 | onCreate() 48 | } catch { 49 | self.error = error 50 | } 51 | } 52 | } 53 | .buttonStyle(.borderedProminent) 54 | .keyboardShortcut(.init(.defaultAction)) 55 | .disabled(newBranchName.isEmpty) 56 | } 57 | .padding(.top) 58 | } 59 | .frame(width: 400) 60 | .padding() 61 | } 62 | .padding() 63 | .cornerRadius(8) 64 | .errorSheet($error) 65 | } 66 | } 67 | 68 | struct CreateNewBranchSheet_Previews: PreviewProvider { 69 | @State private static var isShowing: Branch? = Branch(name: "hoge", isCurrent: false) 70 | 71 | static var previews: some View { 72 | CreateNewBranchSheet( 73 | folder: .init(url: .init(string: "file:///projects/")!), 74 | showingCreateNewBranchFrom: $isShowing) 75 | { 76 | 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /GitClientTests/SyncStateTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SyncStateTests.swift 3 | // GitClientTests 4 | // 5 | // Created by Makoto Aoyama on 2025/04/10. 6 | // 7 | 8 | import Testing 9 | @testable import Changes 10 | import Foundation 11 | 12 | @MainActor 13 | struct SyncStateTests { 14 | @Test func sync() async throws { 15 | let branchName = "_test-fixture" 16 | try await Process.output(GitSwitch(directory: .testFixture!, branchName: branchName)) 17 | let state = SyncState() 18 | state.folderURL = .testFixture! 19 | state.branch = .init(name: branchName, isCurrent: true) 20 | try await state.sync() 21 | 22 | #expect(state.shouldPull == false) 23 | #expect(state.shouldPush == false) 24 | } 25 | 26 | @Test func newBranch() async throws { 27 | let branch = "_test-fixture-should-push-because-new-branch2" 28 | let _ = try? await Process.output(GitCheckoutB(directory: .testFixture!, newBranchName: branch, startPoint: "_test-fixture")) 29 | let state = SyncState() 30 | state.folderURL = .testFixture! 31 | state.branch = .init(name: branch, isCurrent: true) 32 | try await state.sync() 33 | 34 | #expect(state.shouldPull == false) 35 | #expect(state.shouldPush == true) 36 | } 37 | 38 | @Test func shouldPush() async throws { 39 | let branch = "_test-fixture-should-push" 40 | try await Process.output(GitSwitch(directory: .testFixture!, branchName: branch)) 41 | try await Process.output(GitRevert(directory: .testFixture!, commit: "head")) 42 | let state = SyncState() 43 | state.folderURL = .testFixture! 44 | state.branch = .init(name: branch, isCurrent: true) 45 | try await state.sync() 46 | 47 | #expect(state.shouldPull == false) 48 | #expect(state.shouldPush == true) 49 | } 50 | 51 | @Test func shouldPull() async throws { 52 | let branch = "_test-fixture-should-pull" 53 | let _ = try? await Process.output(GitBranchDelete(directory: .testFixture!, branchName: branch)) 54 | try await Process.output(GitCheckoutB(directory: .testFixture!, newBranchName: branch, startPoint: "_test-fixture")) 55 | let state = SyncState() 56 | state.folderURL = .testFixture! 57 | state.branch = .init(name: branch, isCurrent: true) 58 | try await state.sync() 59 | 60 | #expect(state.shouldPull == true) 61 | #expect(state.shouldPush == false) 62 | } 63 | 64 | @Test func syncDetached() async throws { 65 | let tag = "v0.2.0" 66 | try await Process.output(GitCheckout(directory: .testFixture!, commitHash: tag)) 67 | let state = SyncState() 68 | state.folderURL = .testFixture! 69 | state.branch = try await Process.output(GitBranch(directory: .testFixture!)).current 70 | try await state.sync() 71 | 72 | #expect(state.shouldPull == false) 73 | #expect(state.shouldPush == false) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | workflow_dispatch: 8 | jobs: 9 | build: 10 | name: Release 11 | permissions: 12 | contents: write 13 | pull-requests: write 14 | runs-on: macos-26 15 | steps: 16 | - name: Select Xcode 17 | run: | 18 | sudo xcode-select -s /Applications/Xcode_26.0.app/Contents/Developer 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | with: 22 | submodules: true 23 | fetch-depth: 0 24 | - name: Install the Apple certificate 25 | env: 26 | BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} 27 | P12_PASSWORD: ${{ secrets.P12_PASSWORD }} 28 | KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} 29 | run: | 30 | # create variables 31 | CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 32 | PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision 33 | KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db 34 | 35 | # import certificate 36 | echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH 37 | 38 | # create temporary keychain 39 | security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH 40 | security set-keychain-settings -lut 21600 $KEYCHAIN_PATH 41 | security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH 42 | 43 | # import certificate to keychain 44 | security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH 45 | security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH 46 | security list-keychain -d user -s $KEYCHAIN_PATH 47 | - name: Archive App 48 | run: | 49 | xcodebuild -scheme GitClient -configuration Release -archivePath Changes.xcarchive archive 50 | - name: Export App 51 | run: | 52 | xcodebuild -exportArchive -archivePath Changes.xcarchive -exportOptionsPlist ExportOptions.plist -exportPath Changes 53 | - name: Create ZIP Archive 54 | run: | 55 | ditto -c -k --keepParent Changes/Changes.app Changes.zip 56 | - name: Notarize App 57 | env: 58 | AC_USERNAME: ${{ secrets.AC_USERNAME }} 59 | AC_PASSWORD: ${{ secrets.AC_PASSWORD }} 60 | run: | 61 | xcrun notarytool submit Changes.zip --apple-id "$AC_USERNAME" --password "$AC_PASSWORD" --team-id JRA6VW2DG4 --wait 62 | - name: Generate SHA256 Checksum 63 | run: | 64 | shasum -a 256 Changes.zip > Checksum.txt 65 | echo "SHA256 Checksum: $(cat Checksum.txt)" >> $GITHUB_STEP_SUMMARY 66 | - name: Upload Assets to Release 67 | uses: softprops/action-gh-release@v2 68 | env: 69 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 70 | with: 71 | files: | 72 | Changes.zip 73 | Checksum.txt 74 | draft: true 75 | generate_release_notes: true 76 | -------------------------------------------------------------------------------- /GitClient/Views/Folder/CommitLogView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommitLogView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/04/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CommitLogView: View { 11 | @Environment(\.folder) private var folder 12 | @Binding var logStore: LogStore 13 | @Binding var selectionLogID: String? 14 | @Binding var subSelectionLogID: String? 15 | @Binding var showing: FolderViewShowing 16 | @Binding var isRefresh: Bool 17 | @Binding var error: Error? 18 | @State private var selectionLogIDs = Set() 19 | 20 | var body: some View { 21 | List(logStore.logs(), selection: $selectionLogIDs) { log in 22 | logsRow(log) 23 | .task { 24 | await logStore.logViewTask(log) 25 | } 26 | } 27 | .onChange(of: selectionLogIDs) { oldValue, newValue in 28 | if newValue.count <= 1 { 29 | selectionLogID = newValue.first 30 | subSelectionLogID = nil 31 | syncSelection() 32 | return 33 | } 34 | if selectionLogID == nil { 35 | subSelectionLogID = nil 36 | syncSelection() 37 | return 38 | } 39 | if newValue.count == 2, let selectionLogID, newValue.contains(selectionLogID), let subSelectionLogID, newValue.contains(subSelectionLogID) { 40 | return 41 | } 42 | if newValue.count < 4 { 43 | if let added = newValue.first(where: { !oldValue.contains($0) }) { 44 | subSelectionLogID = added 45 | syncSelection() 46 | } 47 | } else { 48 | syncSelection() 49 | } 50 | } 51 | .onChange(of: selectionLogID ?? "") { 52 | syncSelection() 53 | } 54 | .onChange(of: subSelectionLogID ?? "") { 55 | syncSelection() 56 | } 57 | .task { 58 | syncSelection() 59 | } 60 | } 61 | 62 | fileprivate func syncSelection() { 63 | var newValues = Set() 64 | if let selectionLogID { 65 | newValues.insert(selectionLogID) 66 | } 67 | if let subSelectionLogID { 68 | newValues.insert(subSelectionLogID) 69 | } 70 | DispatchQueue.main.async { 71 | selectionLogIDs = newValues 72 | } 73 | } 74 | 75 | fileprivate func logsRow(_ log: Log) -> some View { 76 | return VStack { 77 | switch log { 78 | case .notCommitted: 79 | Text("Uncommitted Changes") 80 | .foregroundStyle(Color.secondary) 81 | case .committed(let commit): 82 | if let folder { 83 | CommitRowView(commit: commit) 84 | .commitContextMenu( 85 | folder: folder, 86 | commit: commit, 87 | logStore: logStore, 88 | isRefresh: $isRefresh, 89 | showing: $showing, 90 | bindingError: $error 91 | ) 92 | } 93 | } 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/CommitDetailHeaderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommitDetailHeaderView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/09/18. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CommitDetailHeaderView: View { 11 | var commit: Commit 12 | @Binding var mergedIn: Commit? 13 | 14 | var body: some View { 15 | ScrollView(.horizontal, showsIndicators: false) { 16 | HStack { 17 | Text(commit.hash.prefix(5)) 18 | .textSelection(.disabled) 19 | .help("Commit Hash: " + commit.hash) 20 | .contextMenu { 21 | Button("Copy " + commit.hash) { 22 | let pasteboard = NSPasteboard.general 23 | pasteboard.declareTypes([.string], owner: nil) 24 | pasteboard.setString(commit.hash, forType: .string) 25 | } 26 | } 27 | Image(systemName: "arrow.right") 28 | HStack(spacing: 0) { 29 | ForEach(commit.parentHashes, id: \.self) { hash in 30 | if hash == commit.parentHashes.first { 31 | NavigationLink(commit.parentHashes[0].prefix(5), value: commit.parentHashes[0]) 32 | .foregroundColor(.accentColor) 33 | } else { 34 | Text(",") 35 | .padding(.trailing, 2) 36 | NavigationLink(commit.parentHashes[1].prefix(5), value: commit.parentHashes[1]) 37 | .foregroundColor(.accentColor) 38 | } 39 | } 40 | } 41 | .textSelection(.disabled) 42 | if let mergedIn { 43 | Divider() 44 | .frame(height: 10) 45 | HStack { 46 | Image(systemName: "arrow.triangle.pull") 47 | NavigationLink(mergedIn.hash.prefix(5), value: mergedIn.hash) 48 | .foregroundColor(.accentColor) 49 | } 50 | .help("Merged in \(mergedIn.hash.prefix(5))") 51 | } 52 | if !commit.tags.isEmpty { 53 | Divider() 54 | .frame(height: 10) 55 | HStack(spacing: 14) { 56 | ForEach(commit.tags, id: \.self) { tag in 57 | Label(tag, systemImage: "tag") 58 | } 59 | } 60 | } 61 | if !commit.branches.isEmpty { 62 | Divider() 63 | .frame(height: 10) 64 | HStack(spacing: 14) { 65 | ForEach(commit.branches, id: \.self) { branch in 66 | Label(branch, systemImage: "arrow.triangle.branch") 67 | .foregroundColor(.secondary) 68 | } 69 | } 70 | } 71 | } 72 | .foregroundColor(.secondary) 73 | .buttonStyle(.link) 74 | } 75 | .padding(.top, 14) 76 | .padding(.horizontal) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /GitClient/Views/Tags/TagsContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TagsContentView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TagsContentView: View { 11 | var folder: Folder 12 | @Binding var showingTags: Bool 13 | @Binding var tags: [String]? 14 | private var filteredTags: [String]? { 15 | guard !filterText.isEmpty else { return tags } 16 | return tags?.filter { $0.lowercased().contains(filterText.lowercased()) } 17 | } 18 | @State private var filterText: String = "" 19 | @State private var selection: String? 20 | @State private var error: Error? 21 | 22 | var body: some View { 23 | VStack(spacing: 0) { 24 | HStack(spacing: 4) { 25 | Image(systemName: "line.3.horizontal.decrease") 26 | TextField(text: $filterText) { 27 | Text("Filter") 28 | } 29 | } 30 | .textFieldStyle(.roundedBorder) 31 | .padding() 32 | Divider() 33 | if let filteredTags { 34 | if filteredTags.isEmpty { 35 | ScrollView { 36 | Text("No Content") 37 | .foregroundStyle(.secondary) 38 | .padding() 39 | .padding(.top, 220) 40 | } 41 | } else { 42 | List(filteredTags, id: \.self, selection: $selection) { tag in 43 | Label(tag, systemImage: "tag") 44 | .contextMenu { 45 | Button("Delete") { 46 | Task { 47 | do { 48 | try await Process.output(GitTagDelete(directory: folder.url, tagname: tag)) 49 | tags = tags?.filter{ $0 != tag} 50 | } catch { 51 | self.error = error 52 | } 53 | } 54 | } 55 | } 56 | } 57 | .onChange(of: selection ?? "", { _, newValue in 58 | Task { 59 | do { 60 | try await Process.output(GitCheckout(directory: folder.url, commitHash: newValue)) 61 | showingTags = false 62 | } catch { 63 | self.error = error 64 | } 65 | } 66 | }) 67 | } 68 | } 69 | } 70 | .scrollContentBackground(.hidden) 71 | .errorSheet($error) 72 | } 73 | } 74 | 75 | #Preview { 76 | @Previewable @State var showingTags = false 77 | @Previewable @State var tags: [String]? = ["v1.0.0", "v1.1.0", "v2.0.0"] 78 | 79 | TagsContentView(folder: .init(url: URL(string: "file:///maoyama/Projects/")!), showingTags: $showingTags, tags: $tags) 80 | .frame(width: 300, height: 660) 81 | } 82 | 83 | #Preview("No Content") { 84 | @Previewable @State var showingTags = false 85 | @Previewable @State var tags: [String]? = [] 86 | 87 | TagsContentView(folder: .init(url: URL(string: "file:///maoyama/Projects/")!), showingTags: $showingTags, tags: $tags) 88 | .frame(width: 300, height: 660) 89 | } 90 | -------------------------------------------------------------------------------- /GitClient/Models/Commands/GitLog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitLog.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2022/09/25. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GitLog: Git { 11 | typealias OutputModel = [Commit] 12 | var arguments: [String] { 13 | var args = [ 14 | "git", 15 | "log", 16 | "--pretty=format:%H" 17 | + .formatSeparator + "%P" 18 | + .formatSeparator + "%an" 19 | + .formatSeparator + "%aE" 20 | + .formatSeparator + "%aI" 21 | + .formatSeparator + "%s" 22 | + .formatSeparator + "%b" 23 | + .formatSeparator + "%D" 24 | + .componentSeparator, 25 | ] 26 | args.append("--topo-order") 27 | 28 | if merges { 29 | args.append("--merges") 30 | } 31 | if ancestryPath { 32 | args.append("--ancestry-path") 33 | } 34 | if reverse { 35 | args.append("--reverse") 36 | } 37 | if number > 0 { 38 | args.append("-\(number)") 39 | } 40 | if skip > 0 { 41 | args.append("--skip=\(skip)") 42 | } 43 | args = args + grep.map { "--grep=\($0)" } 44 | if grepAllMatch { 45 | args.append("--all-match") 46 | } 47 | if !s.isEmpty { 48 | args.append("-S") 49 | args.append(s) 50 | args.append("--pickaxe-regex") 51 | } 52 | if !g.isEmpty { 53 | args.append("-G") 54 | args.append(g) 55 | } 56 | args = args + authors.map { "--author=\($0)" } 57 | if noWalk { 58 | args.append("--no-walk") 59 | } 60 | 61 | if !revisionRange.isEmpty { 62 | args = args + revisionRange 63 | } 64 | 65 | if !paths.isEmpty { 66 | args.append("--") 67 | args = args + paths 68 | } 69 | return args 70 | } 71 | var directory: URL 72 | var merges = false 73 | var ancestryPath = false 74 | var reverse = false 75 | var number = 0 76 | var skip = 0 77 | var grep: [String] = [] 78 | var grepAllMatch = false 79 | var s = "" 80 | var g = "" 81 | var authors:[String] = [] 82 | var noWalk = false 83 | var revisionRange: [String] = [] 84 | var paths: [String] = [] 85 | 86 | func parse(for stdOut: String) throws -> [Commit] { 87 | guard !stdOut.isEmpty else { return [] } 88 | let dropped = stdOut.dropLast(String.componentSeparator.count) 89 | let logs = dropped.components(separatedBy: String.componentSeparator + "\n") 90 | return logs.map { log in 91 | let separated = log.components(separatedBy: String.formatSeparator) 92 | let refs: [String] 93 | if separated[7].isEmpty { 94 | refs = [] 95 | } else { 96 | refs = separated[7].components(separatedBy: ", ") 97 | } 98 | return Commit( 99 | hash: separated[0], 100 | parentHashes: separated[1].components(separatedBy: .whitespacesAndNewlines), 101 | author: separated[2], 102 | authorEmail: separated[3], 103 | authorDate: separated[4], 104 | title: separated[5], 105 | body: separated[6], 106 | branches: refs.filter { !$0.hasPrefix("tag: ") }, 107 | tags: refs.filter { $0.hasPrefix("tag: ") }.map { String($0.dropFirst(5)) } 108 | ) 109 | } 110 | } 111 | } 112 | 113 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/CommitMessageSnippetView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommitMessageSnippetView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/08/11. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CommitMessageSnippetView: View { 11 | @AppStorage(AppStorageKey.commitMessageSnippet.rawValue) var commitMessageSnippet: Data = AppStorageDefaults.commitMessageSnippets 12 | var decodedCommitMessageSnippets: Array { 13 | do { 14 | return try JSONDecoder().decode(Array.self, from: commitMessageSnippet) 15 | } catch { 16 | return [] 17 | } 18 | } 19 | @State private var editCommitMessageSnippet: String = "" 20 | @State private var error: Error? 21 | 22 | var body: some View { 23 | VStack(spacing: 0) { 24 | List { 25 | ForEach(decodedCommitMessageSnippets, id: \.self) { snippet in 26 | HStack { 27 | Button(snippet) { 28 | NotificationCenter.default.post(name: .didSelectCommitMessageSnippetNotification, object: snippet) 29 | } 30 | .buttonStyle(.borderless) 31 | Spacer() 32 | Image(systemName: "line.3.horizontal") 33 | .foregroundColor(.secondary) 34 | } 35 | .contextMenu { 36 | Button("Delete") { 37 | var snippets = decodedCommitMessageSnippets 38 | snippets.removeAll { $0 == snippet } 39 | do { 40 | commitMessageSnippet = try JSONEncoder().encode(snippets) 41 | } catch { 42 | self.error = error 43 | } 44 | } 45 | } 46 | } 47 | .onMove(perform: { indices, newOffset in 48 | var t = decodedCommitMessageSnippets 49 | t.move(fromOffsets: indices, toOffset: newOffset) 50 | do { 51 | commitMessageSnippet = try JSONEncoder().encode(t) 52 | } catch { 53 | self.error = error 54 | } 55 | }) 56 | } 57 | Divider() 58 | HStack(spacing: 0) { 59 | ZStack { 60 | TextEditor(text: $editCommitMessageSnippet) 61 | .padding(.horizontal, 4) 62 | .padding(.vertical, 8) 63 | if editCommitMessageSnippet.isEmpty { 64 | Text("Enter commit message snippet here") 65 | .foregroundColor(.secondary) 66 | .allowsHitTesting(false) 67 | } 68 | } 69 | Divider() 70 | Button("Add") { 71 | let newCommitMessageSnippets = decodedCommitMessageSnippets + [editCommitMessageSnippet] 72 | do { 73 | commitMessageSnippet = try JSONEncoder().encode(newCommitMessageSnippets) 74 | editCommitMessageSnippet = "" 75 | } catch { 76 | self.error = error 77 | } 78 | } 79 | .buttonStyle(.borderedProminent) 80 | .keyboardShortcut(.init(.return)) 81 | .disabled(editCommitMessageSnippet.isEmpty) 82 | .padding() 83 | } 84 | .frame(height: 80) 85 | .background(Color(NSColor.textBackgroundColor)) 86 | } 87 | .errorSheet($error) 88 | } 89 | } 90 | 91 | #Preview { 92 | CommitMessageSnippetView() 93 | } 94 | -------------------------------------------------------------------------------- /GitClient/Extensions/Process+Run.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Process+Run.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2022/09/25. 6 | // 7 | 8 | import Foundation 9 | import os 10 | 11 | struct ProcessError: Error, LocalizedError { 12 | private var description: String 13 | var errorDescription: String? { 14 | return description 15 | } 16 | 17 | init(description: String) { 18 | self.description = description 19 | } 20 | 21 | init(error: Error) { 22 | self.init(description: error.localizedDescription) 23 | } 24 | } 25 | 26 | 27 | extension Process { 28 | struct Output: CustomStringConvertible { 29 | var standardOutput: String 30 | var standartError: String 31 | var description: String { 32 | "Output(standardOutput: \(standardOutput), standardError: \(standartError))" 33 | } 34 | } 35 | 36 | static func output(arguments: [String], currentDirectoryURL: URL?, inputs: [String]=[]) async throws -> Output { 37 | try outputSync(arguments: arguments, currentDirectoryURL: currentDirectoryURL, inputs: inputs) 38 | } 39 | 40 | static private func outputSync(arguments: [String], currentDirectoryURL: URL?, inputs: [String]=[]) throws -> Output { 41 | let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "process") 42 | logger.debug("Process run: arguments: \(arguments), currentDirectoryURL: \(currentDirectoryURL?.description ?? ""), inputs: \(inputs, privacy: .public)") 43 | let output = try run(arguments: arguments, currentDirectoryURL: currentDirectoryURL, inputs: inputs) 44 | logger.debug("Process output of \(arguments): \(output.standardOutput + output.standartError, privacy: .public)") 45 | return output 46 | } 47 | 48 | private static func run(arguments: [String], currentDirectoryURL: URL?, inputs: [String]=[]) throws -> Output { 49 | let process = Process() 50 | let stdOutput = Pipe() 51 | let stdError = Pipe() 52 | let stdInput = Pipe() 53 | 54 | process.executableURL = URL(fileURLWithPath: "/usr/bin/env") 55 | process.arguments = arguments 56 | process.currentDirectoryURL = currentDirectoryURL 57 | process.standardOutput = stdOutput 58 | process.standardError = stdError 59 | process.standardInput = stdInput 60 | 61 | try process.run() 62 | 63 | if !inputs.isEmpty, let writeData = inputs.joined(separator: "\n").data(using: .utf8) { 64 | try stdInput.fileHandleForWriting.write(contentsOf: writeData) 65 | try stdInput.fileHandleForWriting.close() 66 | } 67 | 68 | let stdOut = String(data: stdOutput.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) 69 | let errOut = String(data: stdError.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) 70 | 71 | process.waitUntilExit() 72 | guard process.terminationStatus == 0 else { 73 | let errorMessageWhen = "An error occurred while executing the \"" + arguments.joined(separator: " ") + "\"\n\n" 74 | throw ProcessError( 75 | description: errorMessageWhen + (stdOut ?? "") + "\n" + (errOut ?? "") 76 | ) 77 | } 78 | return .init(standardOutput: stdOut ?? "", standartError: errOut ?? "") 79 | } 80 | 81 | static func output(_ git: G) async throws -> G.OutputModel { 82 | try outputSync(git) 83 | } 84 | 85 | static func outputSync(_ git: G) throws -> G.OutputModel { 86 | let output = try Self.outputSync(arguments: git.arguments, currentDirectoryURL: git.directory) 87 | return try git.parse(for: output.standardOutput) 88 | } 89 | 90 | static func output(_ git: G) async throws -> G.OutputModel { 91 | let output = try await Self.output(arguments: git.arguments, currentDirectoryURL: git.directory, inputs: git.inputs) 92 | return try git.parse(for: output.standardOutput) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /GitClient/Views/Extensions/View+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2022/09/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension View { 11 | func errorSheet(_ error: Binding) -> some View { 12 | sheet(isPresented: .constant(error.wrappedValue != nil)) { 13 | ErrorTextSheet(error: error) 14 | } 15 | } 16 | 17 | func commitContextMenu( 18 | folder: URL, 19 | commit: Commit, 20 | logStore: LogStore, 21 | isRefresh: Binding, 22 | showing: Binding, 23 | bindingError: Binding 24 | ) -> some View { 25 | contextMenu{ 26 | Button("Checkout") { 27 | Task { 28 | do { 29 | try await Process.output(GitCheckout(directory: folder, commitHash: commit.hash)) 30 | isRefresh.wrappedValue = true 31 | } catch { 32 | bindingError.wrappedValue = error 33 | } 34 | } 35 | } 36 | Button("Revert" + (commit.parentHashes.count == 2 ? " -m 1 (mainline: \(commit.parentHashes[0].prefix(7)))" : "")) { 37 | Task { 38 | do { 39 | if commit.parentHashes.count == 2 { 40 | try await Process.output(GitRevert(directory: folder, parentNumber: 1, commit: commit.hash)) 41 | } else { 42 | try await Process.output(GitRevert(directory: folder, commit: commit.hash)) 43 | } 44 | isRefresh.wrappedValue = true 45 | } catch { 46 | bindingError.wrappedValue = error 47 | } 48 | } 49 | } 50 | if commit == logStore.commits.first { 51 | if let notCommitted = logStore.notCommitted { 52 | if notCommitted.diffCached.isEmpty { 53 | Button("Amend") { 54 | showing.wrappedValue.amendCommitAt = commit 55 | } 56 | } 57 | } else { 58 | Button("Amend") { 59 | showing.wrappedValue.amendCommitAt = commit 60 | } 61 | } 62 | } 63 | Button("Tag") { 64 | showing.wrappedValue.createNewTagAt = commit 65 | } 66 | } 67 | } 68 | 69 | func tapGesture(logID: String, selectionLogID: Binding, subSelectionLogID: Binding) -> some View { 70 | onTapGesture { 71 | if NSEvent.modifierFlags.contains(.command) { 72 | // Commandキーが押されている場合(複数選択や選択解除) 73 | if selectionLogID.wrappedValue != nil { 74 | if selectionLogID.wrappedValue == logID && subSelectionLogID.wrappedValue == nil { 75 | // 主選択が同じログIDで、副選択がない場合 → 選択解除 76 | selectionLogID.wrappedValue = nil 77 | } else if selectionLogID.wrappedValue == logID { 78 | // 主選択が同じログIDで、副選択がある場合 → 副選択を主選択に昇格 79 | selectionLogID.wrappedValue = subSelectionLogID.wrappedValue 80 | subSelectionLogID.wrappedValue = nil 81 | } else if subSelectionLogID.wrappedValue == logID { 82 | // 副選択が同じログIDの場合 → 副選択を解除 83 | subSelectionLogID.wrappedValue = nil 84 | } else { 85 | // 主選択・副選択どちらにも該当しない → 副選択に設定 86 | subSelectionLogID.wrappedValue = logID 87 | } 88 | } else { 89 | // 主選択がまだない → 主選択として設定 90 | selectionLogID.wrappedValue = logID 91 | } 92 | } else { 93 | // 通常クリック(Commandキーなし) → 主選択を設定し、副選択を解除 94 | selectionLogID.wrappedValue = logID 95 | subSelectionLogID.wrappedValue = nil 96 | } 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /GitClient/Views/Folder/Sheets/CreateNewTagSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateNewTagSheet.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/09/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CreateNewTagSheet: View { 11 | var folder: Folder 12 | @Binding var showingCreateNewTagAt: Commit? 13 | var onCreate: (() -> Void) 14 | @State private var newTagname = "" 15 | @State private var isLoading = false 16 | @State private var error: Error? 17 | 18 | var body: some View { 19 | VStack { 20 | Text("Create New Tag") 21 | .font(.headline) 22 | VStack(alignment: .leading) { 23 | HStack { 24 | VStack(alignment: .trailing) { 25 | Text("Commit:") 26 | Text("Tag Name:") 27 | .padding(.vertical, 2) 28 | } 29 | VStack(alignment: .leading) { 30 | Text(showingCreateNewTagAt?.hash ?? "") 31 | .textSelection(.enabled) 32 | .padding(.horizontal, 4) 33 | TextField("New tag name", text: $newTagname) 34 | .disabled(isLoading) 35 | } 36 | } 37 | 38 | HStack { 39 | Button("Cancel") { 40 | showingCreateNewTagAt = nil 41 | } 42 | Spacer() 43 | Button("Create") { 44 | Task { 45 | do { 46 | try await Process.output( 47 | GitTagCreate( 48 | directory: folder.url, 49 | tagname: newTagname, 50 | object: showingCreateNewTagAt!.hash 51 | ) 52 | ) 53 | onCreate() 54 | showingCreateNewTagAt = nil 55 | } catch { 56 | self.error = error 57 | } 58 | } 59 | } 60 | .disabled(newTagname.isEmpty) 61 | if isLoading { 62 | ProgressView() 63 | .scaleEffect(0.4) 64 | .frame(width: 102, height: 17) 65 | } else { 66 | Button("Create & Push") { 67 | Task { 68 | isLoading = true 69 | do { 70 | try await Process.output( 71 | GitTagCreate( 72 | directory: folder.url, 73 | tagname: newTagname, 74 | object: showingCreateNewTagAt!.hash 75 | ) 76 | ) 77 | try await Process.output( 78 | GitPush(directory: folder.url, refspec: newTagname) 79 | ) 80 | onCreate() 81 | showingCreateNewTagAt = nil 82 | } catch { 83 | isLoading = false 84 | self.error = error 85 | } 86 | } 87 | } 88 | .buttonStyle(.borderedProminent) 89 | .keyboardShortcut(.init(.defaultAction)) 90 | .disabled(newTagname.isEmpty) 91 | } 92 | } 93 | .padding(.top) 94 | } 95 | .frame(width: 400) 96 | .padding() 97 | } 98 | .padding() 99 | .cornerRadius(8) 100 | .errorSheet($error) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/CommitMessageGenerationContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommitMessageGenerationContentView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/09/13. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CommitMessageGenerationContentView: View { 11 | @Binding var cachedDiffRaw: String 12 | @Binding var commitMessage: String 13 | @Binding var commitMessageIsReponding: Bool 14 | @Binding var generatedCommitMessage: String 15 | @State private var generateCommitMessageTask : Task<(), Never>? 16 | @State private var error: Error? 17 | 18 | var body: some View { 19 | HStack { 20 | if commitMessageIsReponding || !generatedCommitMessage.isEmpty || error != nil { 21 | HStack { 22 | Button { 23 | generateCommitMessageTask?.cancel() 24 | generatedCommitMessage = "" 25 | } label: { 26 | Image(systemName: "xmark") 27 | } 28 | Button { 29 | generateCommitMessageTask?.cancel() 30 | generateCommitMessageTask = Task { 31 | await generateCommitMessage() 32 | } 33 | } label: { 34 | Image(systemName: "arrow.clockwise") 35 | } 36 | if commitMessageIsReponding && generatedCommitMessage.isEmpty { 37 | ProgressView() 38 | .scaleEffect(x: 0.4, y: 0.4, anchor: .leading) 39 | } 40 | ScrollView(.horizontal) { 41 | HStack { 42 | Text(generatedCommitMessage) 43 | if error != nil { 44 | Image(systemName: "exclamationmark.triangle.fill") 45 | .foregroundStyle(.yellow) 46 | Text(error?.localizedDescription ?? "") 47 | .foregroundStyle(.secondary) 48 | } 49 | } 50 | .frame(height: 38) 51 | } 52 | if !generatedCommitMessage.isEmpty { 53 | Button { 54 | commitMessage = generatedCommitMessage 55 | generatedCommitMessage = "" 56 | } label: { 57 | Image(systemName: "arrow.up") 58 | } 59 | } 60 | } 61 | .padding(.horizontal) 62 | } 63 | } 64 | .onChange(of: cachedDiffRaw, initial: true, { oldValue, newValue in 65 | generateCommitMessageTask?.cancel() 66 | generateCommitMessageTask = Task { 67 | await generateCommitMessage() 68 | } 69 | }) 70 | .glassEffect() 71 | .buttonStyle(.plain) 72 | } 73 | 74 | private func generateCommitMessage() async { 75 | generatedCommitMessage = "" 76 | commitMessageIsReponding = true 77 | error = nil 78 | do { 79 | if !cachedDiffRaw.isEmpty { 80 | let stream = SystemLanguageModelService().commitMessage(stagedDiff: cachedDiffRaw) 81 | for try await message in stream { 82 | if !Task.isCancelled { 83 | generatedCommitMessage = message.content.commitMessage ?? "" 84 | } 85 | } 86 | } 87 | } catch { 88 | if !Task.isCancelled { 89 | self.error = error 90 | } 91 | } 92 | commitMessageIsReponding = false 93 | } 94 | } 95 | 96 | #Preview { 97 | @Previewable @State var cachedDiffRaw = "" 98 | @Previewable @State var commitMessage = "Hello" 99 | @Previewable @State var generatedCommitMessage = "Hello" 100 | @Previewable @State var isRespofing = false 101 | 102 | CommitMessageGenerationView( 103 | cachedDiffRaw: $cachedDiffRaw, 104 | commitMessage: $commitMessage, 105 | commitMessageIsReponding: $isRespofing, 106 | generatedCommitMessage: $generatedCommitMessage 107 | ) 108 | } 109 | -------------------------------------------------------------------------------- /GitClient.xcodeproj/xcshareddata/xcschemes/GitClient.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 34 | 35 | 36 | 37 | 40 | 46 | 47 | 48 | 51 | 57 | 58 | 59 | 60 | 61 | 71 | 73 | 79 | 80 | 81 | 82 | 88 | 90 | 96 | 97 | 98 | 99 | 101 | 102 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /GitClient/Views/DiffSummaryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DiffSummaryView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/09/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DiffSummaryView: View { 11 | var fileDiffs: [ExpandableModel] 12 | @State private var summary = "" 13 | @State private var summaryIsResponding = false 14 | @State private var summaryGenerationError: Error? 15 | @State private var generateSummaryTask: Task<(), Never>? 16 | @Environment(\.systemLanguageModelAvailability) private var systemLanguageModelAvailability 17 | 18 | var body: some View { 19 | VStack { 20 | if systemLanguageModelAvailability == .available && (!summary.isEmpty || summaryIsResponding) { 21 | VStack(spacing: 0) { 22 | VStack(alignment: .leading, spacing: 8) { 23 | HStack { 24 | HStack(spacing: 2) { 25 | Image(systemName: "apple.intelligence") 26 | Text("Summary") 27 | } 28 | .foregroundStyle(.tertiary) 29 | Spacer() 30 | Button { 31 | generateSummaryTask?.cancel() 32 | generateSummaryTask = Task { 33 | await generateSummary() 34 | } 35 | } label: { 36 | Image(systemName: "arrow.clockwise") 37 | } 38 | Button { 39 | generateSummaryTask?.cancel() 40 | summary = "" 41 | } label: { 42 | Image(systemName: "xmark") 43 | } 44 | } 45 | .buttonStyle(.plain) 46 | .font(.callout) 47 | if summaryGenerationError != nil { 48 | HStack { 49 | Image(systemName: "exclamationmark.triangle.fill") 50 | .foregroundStyle(.yellow) 51 | Text(summaryGenerationError?.localizedDescription ?? "") 52 | .foregroundStyle(.secondary) 53 | .textSelection(.enabled) 54 | Spacer() 55 | } 56 | } 57 | if summaryIsResponding && summary.isEmpty { 58 | ProgressView() 59 | .scaleEffect(x: 0.4, y: 0.4, anchor: .leading) 60 | .frame(height: 16) 61 | .padding(.leading, 1) 62 | } 63 | if !summary.isEmpty { 64 | Text(summary) 65 | .textSelection(.enabled) 66 | } 67 | } 68 | Divider() 69 | .padding(.top) 70 | } 71 | .padding(.horizontal) 72 | } 73 | } 74 | .onChange(of: fileDiffs.map { $0.model }, initial: true) { 75 | generateSummaryTask?.cancel() 76 | generateSummaryTask = Task { 77 | await generateSummary() 78 | } 79 | } 80 | } 81 | 82 | private func generateSummary() async { 83 | summary = "" 84 | summaryIsResponding = true 85 | summaryGenerationError = nil 86 | do { 87 | let diffRaw = fileDiffs.map { fileDiff in 88 | fileDiff.model.raw 89 | }.joined(separator: "\n") 90 | if !diffRaw.isEmpty { 91 | let stream = SystemLanguageModelService().diffSummary(diffRaw) 92 | for try await text in stream { 93 | if !Task.isCancelled { 94 | summary = text.content.summary ?? "" 95 | } 96 | } 97 | } 98 | } catch { 99 | if !Task.isCancelled { 100 | summaryGenerationError = error 101 | } 102 | } 103 | summaryIsResponding = false 104 | } 105 | 106 | } 107 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/CommitMessageEditor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommitMessageEditorView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/09/06. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CommitMessageEditor: View { 11 | var folder: Folder 12 | @Binding var commitMessage: String 13 | @Binding var generatedCommitMessage: String 14 | @Binding var generatedCommitMessageIsResponding: Bool 15 | @Binding var cachedDiffStat: DiffStat? 16 | @Binding var isAmend: Bool 17 | @Binding var error: Error? 18 | @Binding var cachedDiffRaw: String 19 | @Binding var amendCommit: Commit? 20 | @FocusState private var focused: Bool 21 | var onCommit: () -> Void 22 | 23 | var body: some View { 24 | VStack(spacing: 0) { 25 | Divider() 26 | HStack(alignment: .bottom, spacing: 0) { 27 | VStack(spacing: 0) { 28 | ZStack(alignment: .topLeading) { 29 | TextEditor(text: $commitMessage) 30 | .focused($focused) 31 | .scrollContentBackground(.hidden) 32 | .padding(.top, 16) 33 | .padding(.horizontal, 12) 34 | .font(.body) 35 | if commitMessage.isEmpty && !focused { 36 | Text("Commit Message") 37 | .foregroundColor(.secondary) 38 | .allowsHitTesting(false) 39 | .padding(.top, 16) 40 | .padding(.horizontal, 17) 41 | } 42 | } 43 | .safeAreaBar(edge: .bottom) { 44 | CommitMessageGenerationView( 45 | cachedDiffRaw: $cachedDiffRaw, 46 | commitMessage: $commitMessage, 47 | commitMessageIsReponding: $generatedCommitMessageIsResponding, 48 | generatedCommitMessage: $generatedCommitMessage, 49 | ) 50 | .font(.callout) 51 | .padding(.horizontal) 52 | } 53 | CommitMessageSnippetSuggestionView() 54 | .padding(.trailing) 55 | .font(.callout) 56 | } 57 | Divider() 58 | VStack(alignment: .center, spacing: 11) { 59 | VStack(alignment: .trailing, spacing: 2) { 60 | Label(cachedDiffStat?.files.count.formatted() ?? "-" , systemImage: "doc") 61 | Label(cachedDiffStat?.insertionsTotal.formatted() ?? "-", systemImage: "plus") 62 | Label(cachedDiffStat?.deletionsTotal.formatted() ?? "-", systemImage: "minus") 63 | } 64 | .font(.caption) 65 | Button("Commit") { 66 | Task { 67 | do { 68 | if isAmend { 69 | try await Process.output(GitCommitAmend(directory: folder.url, message: commitMessage)) 70 | } else { 71 | try await Process.output(GitCommit(directory: folder.url, message: commitMessage)) 72 | } 73 | onCommit() 74 | } catch { 75 | self.error = error 76 | } 77 | } 78 | } 79 | .buttonStyle(.borderedProminent) 80 | .keyboardShortcut(.init(.return)) 81 | .disabled(cachedDiffRaw.isEmpty || commitMessage.isEmpty) 82 | Toggle("Amend", isOn: $isAmend) 83 | .font(.caption) 84 | .padding(.trailing, 6) 85 | } 86 | .onChange(of: isAmend) { 87 | if isAmend { 88 | commitMessage = amendCommit?.rawBody ?? "" 89 | } else { 90 | commitMessage = "" 91 | } 92 | } 93 | .padding() 94 | } 95 | } 96 | .onReceive(NotificationCenter.default.publisher(for: .didSelectCommitMessageSnippetNotification), perform: { notification in 97 | if let commitMessage = notification.object as? String { 98 | self.commitMessage = commitMessage 99 | } 100 | }) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL Advanced" 13 | 14 | on: 15 | workflow_dispatch: 16 | # push: 17 | # branches: [ "main" ] 18 | # pull_request: 19 | # branches: [ "main" ] 20 | # schedule: 21 | # - cron: '31 2 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze (${{ matrix.language }}) 26 | # Runner size impacts CodeQL analysis time. To learn more, please see: 27 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 28 | # - https://gh.io/supported-runners-and-hardware-resources 29 | # - https://gh.io/using-larger-runners (GitHub.com only) 30 | # Consider using larger runners or machines with greater resources for possible analysis time improvements. 31 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 32 | permissions: 33 | # required for all workflows 34 | security-events: write 35 | 36 | # required to fetch internal or private CodeQL packs 37 | packages: read 38 | 39 | # only required for workflows in private repositories 40 | actions: read 41 | contents: read 42 | 43 | strategy: 44 | fail-fast: false 45 | matrix: 46 | include: 47 | - language: actions 48 | build-mode: none 49 | - language: swift 50 | build-mode: manual 51 | # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' 52 | # Use `c-cpp` to analyze code written in C, C++ or both 53 | # Use 'java-kotlin' to analyze code written in Java, Kotlin or both 54 | # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 55 | # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, 56 | # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. 57 | # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how 58 | # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages 59 | steps: 60 | - name: Checkout repository 61 | uses: actions/checkout@v4 62 | 63 | # Add any setup steps before running the `github/codeql-action/init` action. 64 | # This includes steps like installing compilers or runtimes (`actions/setup-node` 65 | # or others). This is typically only required for manual builds. 66 | # - name: Setup runtime (example) 67 | # uses: actions/setup-example@v1 68 | 69 | # Initializes the CodeQL tools for scanning. 70 | - name: Initialize CodeQL 71 | uses: github/codeql-action/init@v3 72 | with: 73 | languages: ${{ matrix.language }} 74 | build-mode: ${{ matrix.build-mode }} 75 | # If you wish to specify custom queries, you can do so here or in a config file. 76 | # By default, queries listed here will override any specified in a config file. 77 | # Prefix the list here with "+" to use these queries and those in the config file. 78 | 79 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 80 | # queries: security-extended,security-and-quality 81 | 82 | # If the analyze step fails for one of the languages you are analyzing with 83 | # "We were unable to automatically build your code", modify the matrix above 84 | # to set the build mode to "manual" for that language. Then modify this step 85 | # to build your code. 86 | # ℹ️ Command-line programs to run using the OS shell. 87 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 88 | - if: matrix.build-mode == 'manual' 89 | shell: bash 90 | run: | 91 | sudo xcode-select -s /Applications/Xcode_26.0.app/Contents/Developer 92 | xcodebuild -scheme GitClient CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO -destination "platform=macOS" -disableAutomaticPackageResolution 93 | 94 | - name: Perform CodeQL Analysis 95 | uses: github/codeql-action/analyze@v3 96 | with: 97 | category: "/language:${{matrix.language}}" 98 | -------------------------------------------------------------------------------- /GitClientTests/LogStoreTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LogStoreTests.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2025/04/12. 6 | // 7 | 8 | import Testing 9 | import Foundation 10 | @testable import Changes 11 | 12 | @MainActor 13 | struct LogStoreTests { 14 | @Test func refresh() async throws { 15 | try await Process.output(GitSwitch(directory: .testFixture!, branchName: "_test-fixture")) 16 | let store = LogStore() 17 | store.directory = .testFixture! 18 | store.searchTokens = [] // 検索トークンがなければ全件取得 19 | 20 | await store.refresh() 21 | 22 | #expect(store.commits.count == 29) 23 | #expect(store.logs().first == .committed(store.commits.first!)) 24 | #expect(store.error == nil) 25 | } 26 | 27 | @Test func refreshWithRevisionRange() async throws { 28 | try await Process.output(GitSwitch(directory: .testFixture!, branchName: "_test-fixture")) 29 | let store = LogStore() 30 | store.directory = .testFixture! 31 | store.searchTokens = [.init(kind: .revisionRange, text: "cfae930..")] 32 | 33 | await store.refresh() 34 | 35 | #expect(store.commits.count == 2) 36 | #expect(store.error == nil) 37 | } 38 | 39 | @Test func updateAddsCommits() async throws { 40 | try await Process.output(GitSwitch(directory: .testFixture!, branchName: "_test-fixture")) 41 | let store = LogStore() 42 | store.directory = .testFixture! 43 | store.searchTokens = [] 44 | 45 | await store.refresh() 46 | let oldCount = store.commits.count 47 | await store.update() 48 | let newCount = store.commits.count 49 | 50 | #expect(newCount == oldCount) 51 | #expect(store.error == nil) 52 | } 53 | 54 | @Test func loadMoreAppendsCommits() async throws { 55 | try await Process.output(GitSwitch(directory: .testFixture!, branchName: "_test-fixture")) 56 | let store = LogStore() 57 | store.number = 10 58 | store.directory = .testFixture! 59 | 60 | await store.refresh() 61 | #expect(store.commits.count == 10) 62 | 63 | let first = store.commits.first 64 | await store.logViewTask(.committed(first!)) 65 | #expect(store.commits.count == 10) 66 | 67 | await store.update() 68 | #expect(store.commits.count == 10) 69 | 70 | let last = store.commits.last 71 | await store.logViewTask(.committed(last!)) 72 | #expect(store.commits.count == 20) 73 | 74 | let last2 = store.commits.last 75 | await store.logViewTask(.committed(last2!)) 76 | #expect(store.commits.count == 29) 77 | 78 | #expect(store.error == nil) 79 | } 80 | 81 | @Test func loadMoreAppendsCommitsWithRevisionRange() async throws { 82 | try await Process.output(GitSwitch(directory: .testFixture!, branchName: "_test-fixture")) 83 | let store = LogStore() 84 | store.number = 1 85 | store.directory = .testFixture! 86 | store.searchTokens = [.init(kind: .revisionRange, text: "cfae930..")] 87 | 88 | await store.refresh() 89 | #expect(store.commits.count == 1) 90 | 91 | let first = store.commits.first 92 | await store.logViewTask(.committed(first!)) 93 | #expect(store.commits.count == 2) 94 | 95 | await store.update() 96 | #expect(store.commits.count == 2) 97 | 98 | let last = store.commits.last 99 | await store.logViewTask(.committed(last!)) 100 | #expect(store.commits.count == 2) 101 | 102 | let last2 = store.commits.last 103 | await store.logViewTask(.committed(last2!)) 104 | #expect(store.commits.count == 2) 105 | 106 | #expect(store.error == nil) 107 | } 108 | 109 | @Test func nextLogID() async throws { 110 | try await Process.output(GitSwitch(directory: .testFixture!, branchName: "_test-fixture")) 111 | let store = LogStore() 112 | store.directory = .testFixture! 113 | await store.refresh() 114 | 115 | #expect(store.nextLogID(logID: "d5b9d382e9177ff186d8a1c9f103d0790aeadbac")! == "9c121bd15bfc318d2180038a9e3c38147d954a1f") 116 | #expect(store.nextLogID(logID: store.commits.last!.id) == nil) 117 | 118 | } 119 | 120 | @Test func previousLogID() async throws { 121 | try await Process.output(GitSwitch(directory: .testFixture!, branchName: "_test-fixture")) 122 | let store = LogStore() 123 | store.directory = .testFixture! 124 | await store.refresh() 125 | 126 | #expect(store.previousLogID(logID: "d5b9d382e9177ff186d8a1c9f103d0790aeadbac")! == "6afc3011d0209673b7b876597a40e12c5fc446e2") 127 | #expect(store.previousLogID(logID: store.commits.first!.id) == nil) 128 | 129 | store.notCommitted = .init(diff: "hoge", diffCached: "", status: .init(untrackedFiles: [], unmergedFiles: [])) 130 | #expect(store.previousLogID(logID: store.commits.first!.id)! == Log.notCommitted.id) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /GitClient/Views/Commit/UnstagedView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DiffView.swift 3 | // GitClient 4 | // 5 | // Created by Makoto Aoyama on 2024/05/26. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct UnstagedView: View { 11 | @Binding var fileDiffs: [ExpandableModel] 12 | var status: String 13 | var untrackedFiles: [String] 14 | var onSelectFileDiff: ((FileDiff) -> Void)? 15 | var onSelectChunk: ((FileDiff, Chunk) -> Void)? 16 | var onSelectUntrackedFile: ((String) -> Void)? 17 | @State private var isExpanded = true 18 | 19 | var body: some View { 20 | DisclosureGroup(isExpanded: $isExpanded) { 21 | if fileDiffs.isEmpty && untrackedFiles.isEmpty { 22 | LazyVStack(alignment: .center) { 23 | Label("No Changes", systemImage: "plusminus") 24 | .foregroundStyle(.secondary) 25 | .padding() 26 | .padding() 27 | .padding(.bottom) 28 | .padding(.trailing) 29 | } 30 | } 31 | StagedFileDiffView( 32 | expandableFileDiffs: $fileDiffs, 33 | selectButtonImageSystemName: "plus.circle", 34 | selectButtonHelp: "Stage This Hunk", 35 | onSelectFileDiff: onSelectFileDiff, 36 | onSelectChunk: onSelectChunk 37 | ) 38 | .padding(.leading, 4) 39 | .padding(.top) 40 | 41 | if !untrackedFiles.isEmpty { 42 | VStack(alignment: .leading, spacing: 6) { 43 | Text("Untracked Files") 44 | .foregroundStyle(.secondary) 45 | .font(.caption) 46 | .padding(.top) 47 | ForEach(untrackedFiles, id: \.self) { file in 48 | HStack { 49 | Text(file) 50 | .fontWeight(.bold) 51 | Spacer() 52 | Button { 53 | onSelectUntrackedFile?(file) 54 | } label: { 55 | Image(systemName: "plus.circle") 56 | } 57 | .buttonStyle(.plain) 58 | .help("Stage This File") 59 | .padding(.horizontal) 60 | } 61 | } 62 | } 63 | .padding(.horizontal) 64 | .padding(.bottom) 65 | } 66 | } label: { 67 | SectionHeader(title: "Unstaged Changes", callout: status) 68 | .padding(.leading, 3) 69 | } 70 | .padding(.horizontal) 71 | } 72 | } 73 | 74 | #Preview { 75 | @Previewable @State var fileDiffs: [ExpandableModel] = [] 76 | 77 | let raw = """ 78 | diff --git a/GitBlamePR.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/GitBlamePR.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved 79 | index ca7d6df..b9d9984 100644 80 | --- a/GitBlamePR.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved 81 | +++ b/GitBlamePR.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved 82 | @@ -24,7 +24,7 @@ 83 | "repositoryURL": "https://github.com/onevcat/Kingfisher.git", 84 | "state": { 85 | "branch": null, 86 | - "revision": "7ccfb6cefdb6180cde839310e3dbd5b2d6fefee5", 87 | + "revision": "20d21b3fd7192a42851d7951453e96b41e4e1ed1", 88 | "version": "5.13.3" 89 | } 90 | } 91 | diff --git a/GitBlamePR/View/DetailFooter.swift b/GitBlamePR/View/DetailFooter.swift 92 | index ec290b6..46b0c19 100644 93 | --- a/GitBlamePR/View/DetailFooter.swift 94 | +++ b/GitBlamePR/View/DetailFooter.swift 95 | @@ -6,6 +6,7 @@ 96 | // Copyright © 2020 dev.aoyama. All rights reserved。 97 | 98 | +// test 99 | import SwiftUI 100 | 101 | struct DetailFooter: View { 102 | @@ -36,6 +37,9 @@ struct DetailFooter: View { 103 | } 104 | } 105 | 106 | + 107 | + 108 | +// test2 109 | struct DetailFooter_Previews: PreviewProvider { 110 | static var previews: some View { 111 | Group { 112 | diff --git a/testfile6.txt b/testfile6.txt 113 | new file mode 100644 114 | index 0000000..e69de29 115 | """ 116 | 117 | let diff = try! Diff(raw: raw) 118 | fileDiffs = diff.fileDiffs.map { ExpandableModel(isExpanded: true, model: $0) } 119 | 120 | return ScrollView { 121 | UnstagedView( 122 | fileDiffs: $fileDiffs, 123 | status: "1 file changes", 124 | untrackedFiles: ["Projects/Files/Path.swift", "Projects/Files/Path1.swift"], 125 | onSelectFileDiff: { f in 126 | print(f) 127 | }, 128 | onSelectChunk: { f, c in 129 | print(f, c) 130 | }, 131 | onSelectUntrackedFile: { f in 132 | print(f) 133 | } 134 | ) 135 | } 136 | } 137 | --------------------------------------------------------------------------------