diff --git a/.github/workflows/auto-merge-release.yml b/.github/workflows/auto-merge-release.yml index ba2a4e2..8dfc8f2 100644 --- a/.github/workflows/auto-merge-release.yml +++ b/.github/workflows/auto-merge-release.yml @@ -1,9 +1,13 @@ name: Auto-merge release-please PR on: - pull_request: + # Triggers after the Release workflow completes on main. + # workflow_run always runs in the default-branch context, so it is never + # blocked by the "first-time contributor" approval gate. + workflow_run: + workflows: ["Release"] + types: [completed] branches: [main] - types: [opened, synchronize, reopened] permissions: contents: write @@ -11,21 +15,48 @@ permissions: jobs: auto-merge: - # Only run on release-please PRs - if: github.actor == 'github-actions[bot]' && startsWith(github.head_ref, 'release-please--') runs-on: macos-26 + # Only proceed when the Release workflow succeeded + if: github.event.workflow_run.conclusion == 'success' steps: + - name: Find open release-please PR + id: find-pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_NUMBER=$(gh pr list \ + --repo "${{ github.repository }}" \ + --head "release-please--branches--main" \ + --state open \ + --json number \ + --jq '.[0].number // empty') + + if [[ -z "${PR_NUMBER}" ]]; then + echo "No open release-please PR found; nothing to do." + echo "found=false" >> "$GITHUB_OUTPUT" + else + echo "Found release-please PR #${PR_NUMBER}" + echo "found=true" >> "$GITHUB_OUTPUT" + echo "number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" + fi + - uses: actions/checkout@v4 + if: steps.find-pr.outputs.found == 'true' + with: + ref: release-please--branches--main - name: Build + if: steps.find-pr.outputs.found == 'true' run: swift build - name: Run tests + if: steps.find-pr.outputs.found == 'true' run: swift run GitKitChecks - name: Approve and auto-merge + if: steps.find-pr.outputs.found == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh pr review "${{ github.event.pull_request.number }}" --approve - gh pr merge "${{ github.event.pull_request.number }}" --squash --auto + gh pr review "${{ steps.find-pr.outputs.number }}" --approve + gh pr merge "${{ steps.find-pr.outputs.number }}" --squash --auto diff --git a/Sources/GitKit/Models/FileStatus.swift b/Sources/GitKit/Models/FileStatus.swift index 25a0ebf..ad22742 100644 --- a/Sources/GitKit/Models/FileStatus.swift +++ b/Sources/GitKit/Models/FileStatus.swift @@ -57,8 +57,14 @@ public struct WorkingTreeStatus: Sendable { public let isDetached: Bool public let files: [FileStatus] - public var stagedFiles: [FileStatus] { files.filter(\.isStaged) } - public var unstagedFiles: [FileStatus] { files.filter { $0.hasUnstagedChanges || $0.isUntracked } } + /// Conflicted (unmerged) files. Surfaced separately so they don't masquerade as ordinary + /// staged/unstaged edits — an unmerged entry reports both an index and a worktree state. + public var conflictedFiles: [FileStatus] { files.filter(\.isConflicted) } + public var stagedFiles: [FileStatus] { files.filter { $0.isStaged && !$0.isConflicted } } + public var unstagedFiles: [FileStatus] { + files.filter { !$0.isConflicted && ($0.hasUnstagedChanges || $0.isUntracked) } + } + public var hasConflicts: Bool { files.contains(where: \.isConflicted) } public var hasChanges: Bool { !files.isEmpty } public init( diff --git a/Sources/Gitify/Views/WorkingTreeView.swift b/Sources/Gitify/Views/WorkingTreeView.swift index 98f7ed4..c956fb8 100644 --- a/Sources/Gitify/Views/WorkingTreeView.swift +++ b/Sources/Gitify/Views/WorkingTreeView.swift @@ -127,9 +127,11 @@ struct WorkingTreeView: View { private func fileList(_ status: WorkingTreeStatus) -> some View { // Build a flat ordered list of FileSelection entries for shift-click range computation. + let conflictedFiles = status.conflictedFiles.filter(matches) let stagedFiles = status.stagedFiles.filter(matches) let unstagedFiles = status.unstagedFiles.filter(matches) let orderedSelections: [FileSelection] = + conflictedFiles.map { FileSelection(path: $0.path, staged: false) } + stagedFiles.map { FileSelection(path: $0.path, staged: true) } + unstagedFiles.map { FileSelection(path: $0.path, staged: false) } @@ -138,6 +140,24 @@ struct WorkingTreeView: View { let unstagedPeers = selectedUnstagedFiles(in: status) return List { + if !conflictedFiles.isEmpty { + Section { + ForEach(conflictedFiles) { file in + ConflictFileRow(file: file, viewModel: viewModel, + isSelected: selection.contains(FileSelection(path: file.path, staged: false)), + onResolveInEditor: { conflictTarget = ConflictTarget(path: file.path) }, + onClick: { mods in + handleRowClick(FileSelection(path: file.path, staged: false), + file: file, orderedSelections: orderedSelections, modifiers: mods) + }) + } + } header: { + sectionHeader("Conflicts", count: conflictedFiles.count) { + Text("resolve before committing") + .font(.caption).foregroundStyle(.orange) + } + } + } if !stagedFiles.isEmpty { Section { ForEach(stagedFiles) { file in @@ -361,6 +381,57 @@ private struct StagingFileRow: View { } } +/// A conflicted-file row with always-visible resolution actions, so a merge conflict is +/// obvious and fixable without discovering a hidden context menu. +private struct ConflictFileRow: View { + let file: FileStatus + @Bindable var viewModel: RepositoryViewModel + let isSelected: Bool + var onResolveInEditor: () -> Void + var onClick: (NSEvent.ModifierFlags) -> Void + + var body: some View { + HStack(spacing: 8) { + StatusBadge(file: file) + VStack(alignment: .leading, spacing: 1) { + Text(URL(fileURLWithPath: file.path).lastPathComponent) + Text(conflictDescription).font(.caption).foregroundStyle(.orange).lineLimit(1) + } + Spacer() + Button("Resolve…") { onResolveInEditor() } + .buttonStyle(.borderless).font(.caption) + Menu { + Button("Take Ours (current branch)") { Task { await viewModel.resolveConflict(file, useOurs: true) } } + Button("Take Theirs (incoming)") { Task { await viewModel.resolveConflict(file, useOurs: false) } } + Divider() + Button("Mark Resolved") { Task { await viewModel.markResolved(file) } } + } label: { + Image(systemName: "ellipsis.circle") + } + .menuStyle(.borderlessButton) + .fixedSize() + } + .padding(.vertical, 2) + .listRowBackground(isSelected ? Color.accentColor.opacity(0.2) : Color.clear) + .contentShape(Rectangle()) + .onTapGesture { onClick(currentModifiers()) } + } + + /// Human-readable conflict kind derived from the unmerged XY state pair. + private var conflictDescription: String { + switch (file.indexState, file.worktreeState) { + case (.deleted, .deleted): return "both deleted" + case (.added, .updatedButUnmerged): return "added by us" + case (.updatedButUnmerged, .added): return "added by them" + case (.deleted, .updatedButUnmerged): return "deleted by us" + case (.updatedButUnmerged, .deleted): return "deleted by them" + case (.added, .added): return "both added" + case (.updatedButUnmerged, .updatedButUnmerged): return "both modified" + default: return "conflicted" + } + } +} + /// Colored M/A/D/R/?? badge mirroring git's status letters. struct StatusBadge: View { let file: FileStatus diff --git a/Tests/GitKitChecks/Checks.swift b/Tests/GitKitChecks/Checks.swift index 0550345..1d08f0a 100644 --- a/Tests/GitKitChecks/Checks.swift +++ b/Tests/GitKitChecks/Checks.swift @@ -37,6 +37,12 @@ enum Checks { await test("stash branch, config get/set, conflict resolution", stashConfigConflict) await test("submodules are listed and parsed", submodules) await test("conflict markers parse; resolved file stages", conflictEditor) + await test("ahead/behind counts with configured upstream", aheadBehindUpstream) + await test("ahead/behind inferred without upstream config", aheadBehindInferred) + await test("push clears ahead count", pushClearsAhead) + await test("pull clears behind count", pullClearsBehind) + await test("fetch updates behind count from remote changes", fetchUpdatesBehind) + await test("diverged branch shows both ahead and behind", divergedBranch) } static func conflictEditor() async throws { @@ -758,4 +764,232 @@ enum Checks { await expect(FileManager.default.fileExists(atPath: cloned.appendingPathComponent("f.txt").path), "cloned working tree has file") } + + // MARK: - Push/pull indicator tests + + /// Helper: creates a repo with a local bare remote, pushes main with --set-upstream, + /// and returns (repo, service, bareURL). Caller must clean up `bareURL`. + private static func repoWithRemote() async throws -> (TestRepository, CLIGitService, URL) { + let repo = try await TestRepository() + try await repo.commit("initial", file: "f.txt", contents: "hello\n") + let service = try await repo.service() + + let bare = FileManager.default.temporaryDirectory + .appendingPathComponent("indicator-" + UUID().uuidString + ".git") + try await GitRunner(workingDirectory: nil).run(["init", "--bare", "-q", bare.path]) + + try await repo.git("remote", "add", "origin", bare.path) + try await service.push(remote: "origin", branch: "main", setUpstream: true, + force: false, onProgress: nil) + return (repo, service, bare) + } + + static func aheadBehindUpstream() async throws { + let (repo, service, bare) = try await repoWithRemote() + defer { try? FileManager.default.removeItem(at: bare) } + + // After initial push with --set-upstream, main should be even (ahead=0, behind=0). + var refs = try await service.refs() + var main = try await require(refs.first { $0.isHead && $0.name == "main" }, + "main branch found") + await expectEqual(main.upstream, "origin/main", "upstream is configured") + // When even, git\u{2019}s %(upstream:track) is empty so the parser returns nil. + await expect(main.ahead == nil, "even after push: ahead is nil") + await expect(main.behind == nil, "even after push: behind is nil") + + // Make two local commits \u{2014} ahead should increase to 2. + try await repo.commit("local-1", file: "a.txt", contents: "1\n") + try await repo.commit("local-2", file: "b.txt", contents: "2\n") + + refs = try await service.refs() + main = try await require(refs.first { $0.isHead && $0.name == "main" }, + "main after local commits") + await expectEqual(main.ahead, 2, "two commits ahead") + await expect(main.behind == nil, "still nil behind") + } + + static func aheadBehindInferred() async throws { + let (repo, service, bare) = try await repoWithRemote() + defer { try? FileManager.default.removeItem(at: bare) } + + // Create a branch without configuring its upstream tracking. + try await repo.git("checkout", "-q", "-b", "feature") + // Push without --set-upstream so no tracking config is written. + try await service.push(remote: "origin", branch: "feature", setUpstream: false, + force: false, onProgress: nil) + // Fetch so the remote ref is known locally. + try await service.fetch(remote: "origin", onProgress: nil) + + // Make a local commit on feature. + try await repo.commit("feature work", file: "feat.txt", contents: "work\n") + + let refs = try await service.refs() + let feature = try await require(refs.first { $0.isHead && $0.name == "feature" }, + "feature branch found") + // No configured upstream, but refs() should infer ahead/behind from origin/feature. + await expect(feature.upstream == nil, "no configured upstream") + await expectEqual(feature.ahead, 1, "inferred 1 ahead of origin/feature") + await expectEqual(feature.behind, 0, "inferred 0 behind origin/feature") + } + + static func pushClearsAhead() async throws { + let (repo, service, bare) = try await repoWithRemote() + defer { try? FileManager.default.removeItem(at: bare) } + + // Create local commits. + try await repo.commit("to-push-1", file: "p1.txt", contents: "1\n") + try await repo.commit("to-push-2", file: "p2.txt", contents: "2\n") + + // Verify ahead before push. + var refs = try await service.refs() + var main = try await require(refs.first { $0.isHead }, "HEAD before push") + await expectEqual(main.ahead, 2, "2 ahead before push") + + // Push and re-check \u{2014} ahead should drop to nil (even). + try await service.push(remote: nil, branch: nil, setUpstream: false, + force: false, onProgress: nil) + // Fetch to update remote tracking refs. + try await service.fetch(remote: "origin", onProgress: nil) + + refs = try await service.refs() + main = try await require(refs.first { $0.isHead }, "HEAD after push") + await expect(main.ahead == nil, "ahead cleared after push") + await expect(main.behind == nil, "behind still nil after push") + } + + static func pullClearsBehind() async throws { + let (repo, service, bare) = try await repoWithRemote() + defer { try? FileManager.default.removeItem(at: bare) } + + // Simulate remote-side commits by cloning the bare repo, committing there, + // and pushing back. + let otherDir = FileManager.default.temporaryDirectory + .appendingPathComponent("other-" + UUID().uuidString) + try FileManager.default.createDirectory(at: otherDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: otherDir) } + + let otherURL = try await CLIGitService.clone(url: bare.path, into: otherDir, + name: "clone", onProgress: nil) + let otherRunner = GitRunner(workingDirectory: otherURL) + try await otherRunner.run(["config", "user.name", "Other"]) + try await otherRunner.run(["config", "user.email", "other@example.com"]) + try await otherRunner.run(["config", "commit.gpgsign", "false"]) + try "remote change 1\n".write(to: otherURL.appendingPathComponent("r1.txt"), + atomically: true, encoding: .utf8) + try await otherRunner.run(["add", "r1.txt"]) + try await otherRunner.run(["commit", "-q", "-m", "remote-1"]) + try "remote change 2\n".write(to: otherURL.appendingPathComponent("r2.txt"), + atomically: true, encoding: .utf8) + try await otherRunner.run(["add", "r2.txt"]) + try await otherRunner.run(["commit", "-q", "-m", "remote-2"]) + try await otherRunner.run(["push", "origin", "main"]) + + // Fetch in our repo so we see the remote commits. + try await service.fetch(remote: "origin", onProgress: nil) + + var refs = try await service.refs() + var main = try await require(refs.first { $0.isHead }, "HEAD after fetch") + await expectEqual(main.behind, 2, "2 behind after fetch") + await expect(main.ahead == nil, "0 ahead") + + // Pull should bring us even. + try await service.pull(rebase: false, noRebase: false, onProgress: nil) + + refs = try await service.refs() + main = try await require(refs.first { $0.isHead }, "HEAD after pull") + await expect(main.behind == nil, "behind cleared after pull") + await expect(main.ahead == nil, "ahead still nil after pull") + + // Verify the files actually arrived. + await expect(FileManager.default.fileExists(atPath: + repo.url.appendingPathComponent("r1.txt").path), "pulled file r1 exists") + await expect(FileManager.default.fileExists(atPath: + repo.url.appendingPathComponent("r2.txt").path), "pulled file r2 exists") + } + + static func fetchUpdatesBehind() async throws { + let (repo, service, bare) = try await repoWithRemote() + _ = repo // prevent deinit from cleaning up the temp directory + defer { try? FileManager.default.removeItem(at: bare) } + + // Before any remote changes, behind should be nil (even). + var refs = try await service.refs() + var main = try await require(refs.first { $0.isHead }, "HEAD before remote changes") + await expect(main.behind == nil, "initially nil behind (even)") + + // Push a commit to the bare remote from a second clone. + let otherDir = FileManager.default.temporaryDirectory + .appendingPathComponent("fetcher-" + UUID().uuidString) + try FileManager.default.createDirectory(at: otherDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: otherDir) } + + let otherURL = try await CLIGitService.clone(url: bare.path, into: otherDir, + name: "clone", onProgress: nil) + let otherRunner = GitRunner(workingDirectory: otherURL) + try await otherRunner.run(["config", "user.name", "Other"]) + try await otherRunner.run(["config", "user.email", "other@example.com"]) + try await otherRunner.run(["config", "commit.gpgsign", "false"]) + try "fetched content\n".write(to: otherURL.appendingPathComponent("remote.txt"), + atomically: true, encoding: .utf8) + try await otherRunner.run(["add", "remote.txt"]) + try await otherRunner.run(["commit", "-q", "-m", "remote commit"]) + try await otherRunner.run(["push", "origin", "main"]) + + // Before fetch, our repo doesn\u{2019}t know about the remote commit yet. + refs = try await service.refs() + main = try await require(refs.first { $0.isHead }, "HEAD before fetch") + await expect(main.behind == nil, "behind still nil before fetch") + + // After fetch, behind should update to 1. + try await service.fetch(remote: "origin", onProgress: nil) + + refs = try await service.refs() + main = try await require(refs.first { $0.isHead }, "HEAD after fetch") + await expectEqual(main.behind, 1, "behind is 1 after fetch") + await expect(main.ahead == nil, "ahead unchanged (nil = even)") + } + + static func divergedBranch() async throws { + let (repo, service, bare) = try await repoWithRemote() + defer { try? FileManager.default.removeItem(at: bare) } + + // Push a commit from a second clone to create remote-side divergence. + let otherDir = FileManager.default.temporaryDirectory + .appendingPathComponent("diverge-" + UUID().uuidString) + try FileManager.default.createDirectory(at: otherDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: otherDir) } + + let otherURL = try await CLIGitService.clone(url: bare.path, into: otherDir, + name: "clone", onProgress: nil) + let otherRunner = GitRunner(workingDirectory: otherURL) + try await otherRunner.run(["config", "user.name", "Other"]) + try await otherRunner.run(["config", "user.email", "other@example.com"]) + try await otherRunner.run(["config", "commit.gpgsign", "false"]) + try "diverge remote\n".write(to: otherURL.appendingPathComponent("remote.txt"), + atomically: true, encoding: .utf8) + try await otherRunner.run(["add", "remote.txt"]) + try await otherRunner.run(["commit", "-q", "-m", "remote diverge"]) + try await otherRunner.run(["push", "origin", "main"]) + + // Make a local commit (diverging from the remote). + try await repo.commit("local diverge", file: "local.txt", contents: "local\n") + + // Fetch so we see the remote commit. + try await service.fetch(remote: "origin", onProgress: nil) + + var refs = try await service.refs() + var main = try await require(refs.first { $0.isHead }, "HEAD diverged") + await expectEqual(main.ahead, 1, "diverged: 1 ahead") + await expectEqual(main.behind, 1, "diverged: 1 behind") + + // Force push should clear ahead (and make remote match local). + try await service.push(remote: nil, branch: nil, setUpstream: false, + force: true, onProgress: nil) + try await service.fetch(remote: "origin", onProgress: nil) + + refs = try await service.refs() + main = try await require(refs.first { $0.isHead }, "HEAD after force push") + await expect(main.ahead == nil, "ahead cleared after force push") + await expect(main.behind == nil, "behind cleared after force push") + } }