├── 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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | 
8 | 
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 |
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 |
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 |
--------------------------------------------------------------------------------