From 23a5f732eba51fc3b570985005fefb2bcedb5d80 Mon Sep 17 00:00:00 2001 From: mirakyux Date: Sat, 29 Aug 2026 08:06:26 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(git):=20=E5=AE=8C=E5=96=84=E6=BA=90?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=AE=A1=E7=90=86=E4=B8=8E=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Lithe/Core/Rust/RustCoreBridge.swift | 51 +- .../Lithe/Core/Rust/RustGitOperations.swift | 97 +- .../GitReferenceOperationsTests.swift | 107 ++ rust/lithe-core/src/git/mod.rs | 1027 ++++++++++++++++- rust/lithe-core/src/git/mutations.rs | 11 +- rust/lithe-core/src/tests/git.rs | 750 +++++++++++- shared/contracts/rust-core-api.md | 76 +- shared/fixtures/git/diff.json | 12 + shared/fixtures/git/write.json | 42 + windows/tauri/src-tauri/src/platform.rs | 232 +++- .../editor/components/code-editor.tsx | 3 + .../editor/components/monaco-editor.tsx | 8 +- .../hooks/use-file-tree-presentation.ts | 31 + .../styles/file-explorer-tree.css | 19 +- .../features/git/api/git-branches-api.test.ts | 117 ++ .../src/features/git/api/git-branches-api.ts | 154 ++- .../features/git/api/git-commits-api.test.ts | 73 ++ .../src/features/git/api/git-commits-api.ts | 88 ++ .../features/git/api/git-reference-payload.ts | 14 + .../features/git/api/git-remotes-api.test.ts | 34 +- .../src/features/git/api/git-remotes-api.ts | 17 + .../features/git/api/git-status-api.test.ts | 85 ++ .../src/features/git/api/git-status-api.ts | 92 +- .../git/api/git-worktrees-api.test.ts | 58 + .../src/features/git/api/git-worktrees-api.ts | 25 + .../components/diff/git-diff-editor-stack.tsx | 53 +- .../git/components/diff/git-diff-text.tsx | 2 + .../git/components/git-commit-history.tsx | 279 ++++- .../git/components/git-commit-panel.tsx | 104 +- .../src/features/git/components/git-view.tsx | 96 +- .../components/log/git-commit-file-tree.tsx | 228 ++-- .../git/components/log/git-commit-table.tsx | 156 ++- .../components/log/git-log-tool-window.tsx | 164 ++- .../status/git-status-file-item.tsx | 48 +- .../components/status/git-status-panel.tsx | 697 ++++++++--- .../git/hooks/use-git-diff-actions.ts | 9 +- .../src/features/git/stores/git.store.test.ts | 31 + .../src/features/git/stores/git.store.ts | 28 + .../src/features/git/types/git-diff.types.ts | 1 + .../git/utils/git-history-selection.test.ts | 50 + .../git/utils/git-history-selection.ts | 64 + .../git/utils/git-reference-actions.test.ts | 73 ++ .../git/utils/git-reference-actions.ts | 91 ++ .../git/utils/git-status-selection.test.ts | 70 ++ .../git/utils/git-status-selection.ts | 66 ++ .../git/utils/multi-file-diff.test.ts | 39 + .../src/features/git/utils/multi-file-diff.ts | 81 ++ .../sidebar/components/sidebar-tree.tsx | 1 + windows/tauri/src/i18n/locale.ts | 151 +++ .../tauri/src/platform/core-result-adapter.ts | 1 + 50 files changed, 5177 insertions(+), 629 deletions(-) create mode 100644 macos/Tests/LitheTests/GitReferenceOperationsTests.swift create mode 100644 windows/tauri/src/features/file-explorer/hooks/use-file-tree-presentation.ts create mode 100644 windows/tauri/src/features/git/api/git-branches-api.test.ts create mode 100644 windows/tauri/src/features/git/api/git-commits-api.test.ts create mode 100644 windows/tauri/src/features/git/api/git-reference-payload.ts create mode 100644 windows/tauri/src/features/git/api/git-status-api.test.ts create mode 100644 windows/tauri/src/features/git/api/git-worktrees-api.test.ts create mode 100644 windows/tauri/src/features/git/utils/git-history-selection.test.ts create mode 100644 windows/tauri/src/features/git/utils/git-history-selection.ts create mode 100644 windows/tauri/src/features/git/utils/git-reference-actions.test.ts create mode 100644 windows/tauri/src/features/git/utils/git-reference-actions.ts create mode 100644 windows/tauri/src/features/git/utils/git-status-selection.test.ts create mode 100644 windows/tauri/src/features/git/utils/git-status-selection.ts create mode 100644 windows/tauri/src/features/git/utils/multi-file-diff.test.ts create mode 100644 windows/tauri/src/features/git/utils/multi-file-diff.ts diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index c544c1f7f..d79d8a498 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -1720,11 +1720,24 @@ struct RustCoreBridge: Sendable { let input: String? } + private struct GitReferenceRequest: Encodable { + let fullName: String + let shortName: String + let kind: String + + init(_ reference: GitReference) { + fullName = reference.fullName + shortName = reference.shortName + kind = reference.kind.rawValue + } + } + private struct GitWriteRequest: Encodable { let root: String let operation: String let paths: [String] let reference: String? + let gitReference: GitReferenceRequest? let referenceKind: String? let revision: String? let name: String? @@ -1743,6 +1756,8 @@ struct RustCoreBridge: Sendable { let root: String let pathspecs: [String] let reference: String? + let gitReference: GitReferenceRequest? + let targetGitReference: GitReferenceRequest? let commit: String? let staged: Bool let untracked: Bool @@ -1774,7 +1789,9 @@ struct RustCoreBridge: Sendable { private struct GitComparisonRequest: Encodable { let root: String - let reference: String + let reference: String? + let gitReference: GitReferenceRequest? + let targetGitReference: GitReferenceRequest? } private struct GitStashesRequest: Encodable { @@ -1783,7 +1800,7 @@ struct RustCoreBridge: Sendable { private struct GitCheckoutPreflightRequest: Encodable { let root: String - let reference: String + let gitReference: GitReferenceRequest } private struct GitOperationStateRequest: Encodable { @@ -1800,7 +1817,8 @@ struct RustCoreBridge: Sendable { private struct GitIntegrationPreflightRequest: Encodable { let root: String - let reference: String + let reference: String? + let gitReference: GitReferenceRequest? let operation: String } @@ -2613,6 +2631,7 @@ struct RustCoreBridge: Sendable { operation: String, paths: [String] = [], reference: String? = nil, + gitReference: GitReference? = nil, referenceKind: String? = nil, revision: String? = nil, name: String? = nil, @@ -2633,6 +2652,7 @@ struct RustCoreBridge: Sendable { operation: operation, paths: paths, reference: reference, + gitReference: gitReference.map(GitReferenceRequest.init), referenceKind: referenceKind, revision: revision, name: name, @@ -2649,12 +2669,12 @@ struct RustCoreBridge: Sendable { ) } - func gitCheckoutPreflight(at rootURL: URL, reference: String) -> GitCheckoutPreflightPayload? { + func gitCheckoutPreflight(at rootURL: URL, reference: GitReference) -> GitCheckoutPreflightPayload? { execute( command: "git.checkoutPreflight", payload: GitCheckoutPreflightRequest( root: rootURL.standardizedFileURL.path, - reference: reference + gitReference: GitReferenceRequest(reference) ) ) } @@ -2688,7 +2708,8 @@ struct RustCoreBridge: Sendable { func gitIntegrationPreflight( at rootURL: URL, - reference: String, + reference: String? = nil, + gitReference: GitReference? = nil, operation: String ) -> GitIntegrationPreflightPayload? { execute( @@ -2696,6 +2717,7 @@ struct RustCoreBridge: Sendable { payload: GitIntegrationPreflightRequest( root: rootURL.standardizedFileURL.path, reference: reference, + gitReference: gitReference.map(GitReferenceRequest.init), operation: operation ) ) @@ -2706,6 +2728,7 @@ struct RustCoreBridge: Sendable { operation: String, paths: [String] = [], reference: String? = nil, + gitReference: GitReference? = nil, referenceKind: String? = nil, revision: String? = nil, name: String? = nil, @@ -2726,6 +2749,7 @@ struct RustCoreBridge: Sendable { operation: operation, paths: paths, reference: reference, + gitReference: gitReference.map(GitReferenceRequest.init), referenceKind: referenceKind, revision: revision, name: name, @@ -2746,6 +2770,8 @@ struct RustCoreBridge: Sendable { at rootURL: URL, pathspecs: [String], reference: String? = nil, + gitReference: GitReference? = nil, + targetGitReference: GitReference? = nil, commit: String? = nil, staged: Bool, untracked: Bool, @@ -2758,6 +2784,8 @@ struct RustCoreBridge: Sendable { root: rootURL.standardizedFileURL.path, pathspecs: pathspecs, reference: reference, + gitReference: gitReference.map(GitReferenceRequest.init), + targetGitReference: targetGitReference.map(GitReferenceRequest.init), commit: commit, staged: staged, untracked: untracked, @@ -2829,12 +2857,19 @@ struct RustCoreBridge: Sendable { ) } - func gitComparison(at rootURL: URL, reference: String) -> GitComparisonPayload? { + func gitComparison( + at rootURL: URL, + reference: String? = nil, + gitReference: GitReference? = nil, + targetGitReference: GitReference? = nil + ) -> GitComparisonPayload? { execute( command: "git.comparison", payload: GitComparisonRequest( root: rootURL.standardizedFileURL.path, - reference: reference + reference: reference, + gitReference: gitReference.map(GitReferenceRequest.init), + targetGitReference: targetGitReference.map(GitReferenceRequest.init) ) ) } diff --git a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift index cbc5f34f1..b749c78e8 100644 --- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift @@ -60,6 +60,7 @@ struct RustGitOperations: GitOperations, Sendable { operation: String, paths: [String] = [], reference: String? = nil, + gitReference: GitReference? = nil, referenceKind: GitReferenceKind? = nil, revision: String? = nil, name: String? = nil, @@ -78,6 +79,7 @@ struct RustGitOperations: GitOperations, Sendable { operation: operation, paths: paths, reference: reference, + gitReference: gitReference, referenceKind: referenceKind?.rawValue, revision: revision, name: name, @@ -134,39 +136,30 @@ struct RustGitOperations: GitOperations, Sendable { write( at: rootURL, operation: "createBranch", - reference: reference.fullName, + gitReference: reference, name: name, checkout: checkout ) } func renameBranch(_ reference: GitReference, to name: String, at rootURL: URL) -> GitProcessResult? { - write(at: rootURL, operation: "renameBranch", reference: reference.fullName, name: name) + write(at: rootURL, operation: "renameBranch", gitReference: reference, name: name) } func deleteBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { - write(at: rootURL, operation: "deleteBranch", reference: reference.fullName) + write(at: rootURL, operation: "deleteBranch", gitReference: reference) } func mergeBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { - write(at: rootURL, operation: "merge", reference: reference.fullName) + write(at: rootURL, operation: "merge", gitReference: reference) } func rebaseCurrentBranch(onto reference: GitReference, at rootURL: URL) -> GitProcessResult? { - write(at: rootURL, operation: "rebase", reference: reference.fullName) + write(at: rootURL, operation: "rebase", gitReference: reference) } func checkoutAndRebase(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { - write( - at: rootURL, - operation: "checkoutAndRebase", - reference: reference.fullName, - referenceKind: reference.kind - ) - } - - func updateCurrentBranch(at rootURL: URL, strategy: GitPullStrategy = .ffOnly) -> GitProcessResult? { - write(at: rootURL, operation: "pull", mode: strategy.rawValue) + write(at: rootURL, operation: "checkoutAndRebase", gitReference: reference) } func pullRemoteReference( @@ -177,12 +170,15 @@ struct RustGitOperations: GitOperations, Sendable { write( at: rootURL, operation: "pull", - reference: reference.fullName, - referenceKind: reference.kind, + gitReference: reference, mode: strategy.rawValue ) } + func updateCurrentBranch(at rootURL: URL, strategy: GitPullStrategy = .ffOnly) -> GitProcessResult? { + write(at: rootURL, operation: "pull", mode: strategy.rawValue) + } + /// Staged files still containing conflict markers. func conflictMarkerPaths(at rootURL: URL) -> [String] { core.gitConflictMarkerPaths(at: rootURL)?.paths ?? [] @@ -194,11 +190,22 @@ struct RustGitOperations: GitOperations, Sendable { operation: GitIntegrationOperation, at rootURL: URL ) -> GitIntegrationPreflightState? { - guard let payload = core.gitIntegrationPreflight( - at: rootURL, - reference: target.revision, - operation: operation.rawValue - ) else { return nil } + let payload: RustCoreBridge.GitIntegrationPreflightPayload? + switch target { + case .reference(let reference): + payload = core.gitIntegrationPreflight( + at: rootURL, + gitReference: reference, + operation: operation.rawValue + ) + case .commit: + payload = core.gitIntegrationPreflight( + at: rootURL, + reference: target.revision, + operation: operation.rawValue + ) + } + guard let payload else { return nil } return GitIntegrationPreflightState( blockingPaths: payload.blockingPaths, blocksEntirely: payload.blocksEntirely @@ -230,8 +237,7 @@ struct RustGitOperations: GitOperations, Sendable { write( at: rootURL, operation: "checkout", - reference: reference.fullName, - referenceKind: reference.kind, + gitReference: reference, force: force, autoStash: autoStash ) @@ -239,7 +245,7 @@ struct RustGitOperations: GitOperations, Sendable { /// Returns the working-tree paths that would block checking out `reference`. func checkoutBlockingPaths(for reference: GitReference, at rootURL: URL) -> [String] { - core.gitCheckoutPreflight(at: rootURL, reference: reference.fullName)?.blockingPaths ?? [] + core.gitCheckoutPreflight(at: rootURL, reference: reference)?.blockingPaths ?? [] } func operationState(at rootURL: URL) -> GitOperationState? { @@ -273,7 +279,7 @@ struct RustGitOperations: GitOperations, Sendable { } func push(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { - write(at: rootURL, operation: "push", reference: reference.fullName) + write(at: rootURL, operation: "push", gitReference: reference) } func cloneRepository(from remote: String, to destination: URL) -> GitProcessResult? { @@ -382,6 +388,24 @@ struct RustGitOperations: GitOperations, Sendable { )?.makeDocument() } + func comparisonDiffDocument( + at rootURL: URL, + reference: GitReference, + targetReference: GitReference?, + pathspecs: [String], + whitespace: GitDiffWhitespaceMode = .doNotIgnore + ) -> DiffDocument? { + core.gitDiff( + at: rootURL, + pathspecs: pathspecs, + gitReference: reference, + targetGitReference: targetReference, + staged: false, + untracked: false, + ignoreAllWhitespace: whitespace == .ignoreAllWhitespace + )?.makeDocument() + } + func applyPatch( _ patch: String, at rootURL: URL, @@ -421,7 +445,7 @@ struct RustGitOperations: GitOperations, Sendable { for reference: GitReference, at rootURL: URL ) -> GitBranchComparison? { - guard let payload = core.gitComparison(at: rootURL, reference: reference.fullName) else { + guard let payload = core.gitComparison(at: rootURL, gitReference: reference) else { return nil } return GitBranchComparison( @@ -432,6 +456,25 @@ struct RustGitOperations: GitOperations, Sendable { ) } + func comparison( + from reference: GitReference, + to target: GitReference, + at rootURL: URL + ) -> GitBranchComparison? { + guard let payload = core.gitComparison( + at: rootURL, + gitReference: reference, + targetGitReference: target + ) else { return nil } + return GitBranchComparison( + reference: reference, + targetReference: target, + files: payload.files.map { file in + GitBranchComparisonFile(status: file.status, path: file.path) + } + ) + } + func stashes(at rootURL: URL) -> [GitStash]? { core.gitStashes(at: rootURL)?.stashes.map { stash in GitStash( diff --git a/macos/Tests/LitheTests/GitReferenceOperationsTests.swift b/macos/Tests/LitheTests/GitReferenceOperationsTests.swift new file mode 100644 index 000000000..e96e10fed --- /dev/null +++ b/macos/Tests/LitheTests/GitReferenceOperationsTests.swift @@ -0,0 +1,107 @@ +import Foundation +import Testing +@testable import Lithe +@testable import LitheGitModule + +@Suite("Git reference operations", .serialized) +struct GitReferenceOperationsTests { + @Test + func remoteReferenceWorkflowsUseCompleteIdentityThroughRustCore() throws { + let fixture = try GitReferenceFixture() + let repository = fixture.repository + let mainName = try fixture.git(["branch", "--show-current"]) + let mainReference = GitReference( + fullName: "refs/heads/\(mainName)", + shortName: mainName, + kind: .local, + isCurrent: true, + upstreamShortName: nil + ) + + try fixture.git(["switch", "-q", "-c", "feature"]) + try Data("feature\n".utf8).write(to: repository.appendingPathComponent("tracked.txt")) + try fixture.git(["commit", "-qam", "feature"]) + try fixture.git(["update-ref", "refs/remotes/origin/feature", "refs/heads/feature"]) + try fixture.git(["switch", "-q", mainName]) + try fixture.git(["branch", "-D", "feature"]) + + let remoteReference = GitReference( + fullName: "refs/remotes/origin/feature", + shortName: "origin/feature", + kind: .remote, + isCurrent: false, + upstreamShortName: nil + ) + let operations = RustGitOperations(core: RustCoreBridge()) + + let comparison = operations.comparison( + from: mainReference, + to: remoteReference, + at: repository + ) + #expect(comparison?.files.map(\.path) == ["tracked.txt"]) + + let checkoutAndRebase = operations.checkoutAndRebase(remoteReference, at: repository) + #expect(checkoutAndRebase?.exitCode == 0) + #expect(try fixture.git(["branch", "--show-current"]) == "feature") + + try fixture.git(["switch", "-q", mainName]) + let pull = operations.pullRemoteReference( + remoteReference, + strategy: .merge, + at: repository + ) + #expect(pull?.exitCode == 0) + #expect(try String(contentsOf: repository.appendingPathComponent("tracked.txt")) == "feature\n") + } +} + +private final class GitReferenceFixture { + let repository: URL + + init() throws { + repository = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-git-reference-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: repository, withIntermediateDirectories: true) + try git(["init", "-q"]) + try git(["config", "user.email", "tests@lithe.local"]) + try git(["config", "user.name", "Lithe Tests"]) + try git(["config", "core.autocrlf", "false"]) + try git(["remote", "add", "origin", "."]) + try Data("main\n".utf8).write(to: repository.appendingPathComponent("tracked.txt")) + try git(["add", "tracked.txt"]) + try git(["commit", "-qm", "initial"]) + } + + deinit { + try? FileManager.default.removeItem(at: repository) + } + + @discardableResult + func git(_ arguments: [String]) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = arguments + process.currentDirectoryURL = repository + let standardOutput = Pipe() + let standardError = Pipe() + process.standardOutput = standardOutput + process.standardError = standardError + try process.run() + process.waitUntilExit() + let output = standardOutput.fileHandleForReading.readDataToEndOfFile() + let error = standardError.fileHandleForReading.readDataToEndOfFile() + guard process.terminationStatus == 0 else { + throw GitReferenceFixtureError.commandFailed( + arguments, + String(decoding: error, as: UTF8.self) + ) + } + return String(decoding: output, as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +private enum GitReferenceFixtureError: Error { + case commandFailed([String], String) +} diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 8132218db..4b48bf998 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -13,6 +13,7 @@ use crate::protocol::{ }; use serde::{Deserialize, Serialize}; use std::cell::RefCell; +use std::collections::HashSet; use std::io::Read; use std::io::Write; #[cfg(target_os = "windows")] @@ -216,6 +217,18 @@ pub struct GitStashRestoreResponse { pub conflicted_paths: Vec, } +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Complete identity of a Git reference supplied by a platform client. +pub struct GitReferenceRequest { + /// Fully qualified reference, such as `refs/remotes/origin/main`. + pub full_name: String, + /// User-facing short name, such as `origin/main`. + pub short_name: String, + /// Reference namespace: `local`, `remote`, or `tag`. + pub kind: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] /// Typed mutation request translated into a controlled Git invocation. @@ -227,11 +240,18 @@ pub struct GitWriteRequest { pub paths: Vec, #[serde(default)] pub reference: Option, + /// Preferred typed reference. Legacy callers may still use `reference` and + /// `referenceKind`, but new cross-platform workflows send all identity fields. + #[serde(default)] + pub git_reference: Option, /// Reference category used by checkout: `local`, `remote`, or `tag`. #[serde(default)] pub reference_kind: Option, #[serde(default)] pub revision: Option, + /// Commit revisions selected by an operation that rewrites a contiguous range. + #[serde(default)] + pub revisions: Vec, #[serde(default)] pub name: Option, #[serde(default)] @@ -264,6 +284,12 @@ pub struct GitDiffRequest { #[serde(default)] pub reference: Option, #[serde(default)] + pub git_reference: Option, + /// Optional typed comparison target. When present, `gitReference` is the + /// base and Core constructs the validated two-reference range. + #[serde(default)] + pub target_git_reference: Option, + #[serde(default)] pub commit: Option, #[serde(default)] pub staged: bool, @@ -317,7 +343,13 @@ pub struct GitCommitFilesRequest { /// Request to compare a reference with the current checkout. pub struct GitComparisonRequest { pub root: String, - pub reference: String, + #[serde(default)] + pub reference: Option, + #[serde(default)] + pub git_reference: Option, + /// Optional typed target for a comparison between two references. + #[serde(default)] + pub target_git_reference: Option, } #[derive(Debug, Deserialize)] @@ -332,7 +364,10 @@ pub struct GitStashesRequest { /// Request to identify local edits that would block switching references. pub struct GitCheckoutPreflightRequest { pub root: String, - pub reference: String, + #[serde(default)] + pub reference: Option, + #[serde(default)] + pub git_reference: Option, } #[derive(Debug, Deserialize)] @@ -347,7 +382,10 @@ pub struct GitConflictMarkerRequest { /// Request to determine whether a merge or rebase can start safely. pub struct GitIntegrationPreflightRequest { pub root: String, - pub reference: String, + #[serde(default)] + pub reference: Option, + #[serde(default)] + pub git_reference: Option, /// Either "merge" or "rebase"; the two have different tolerances for a dirty tree. pub operation: String, } @@ -469,12 +507,38 @@ fn write_with_trace(request: GitWriteRequest) -> Result arguments = vec!["add".into(), "--all".into()], "commit" => { let message = required_text(request.message.as_deref(), "commit message")?; + if !request.paths.is_empty() { + let paths = validate_paths(&request.paths)?; + let stage_arguments = ["add", "-A", "--"] + .into_iter() + .map(String::from) + .chain(paths.clone()) + .collect::>(); + let staged = execute_git(&root, &stage_arguments, None)?; + if staged.exit_code != 0 { + return Ok(staged); + } + + arguments = vec!["commit".into()]; + if request.amend { + arguments.push("--amend".into()); + } + arguments.extend(["--only".into(), "-m".into(), message, "--".into()]); + arguments.extend(paths); + return execute_git(&root, &arguments, None); + } arguments = vec!["commit".into()]; if request.amend { arguments.push("--amend".into()); } arguments.extend(["-m".into(), message]); } + "ignore" => { + return append_git_ignore_patterns(&root, &request.paths, GitIgnoreTarget::Repository) + } + "exclude" => { + return append_git_ignore_patterns(&root, &request.paths, GitIgnoreTarget::LocalExclude) + } "cherryPick" => { arguments = vec![ "cherry-pick".into(), @@ -502,9 +566,22 @@ fn write_with_trace(request: GitWriteRequest) -> Result { + let revision = validated_revision(request.revision.as_deref())?; + let message = required_text(request.message.as_deref(), "commit message")?; + return edit_commit_message(&root, &revision, &message); + } + "deleteCommit" => { + let revision = validated_revision(request.revision.as_deref())?; + return delete_commit(&root, &revision); + } + "squashCommits" => { + let message = required_text(request.message.as_deref(), "commit message")?; + return squash_commits(&root, &request.revisions, &message); + } "createBranch" => { let name = validated_branch_name(&root, request.name.as_deref())?; - let reference = validated_reference(request.reference.as_deref())?; + let reference = write_request_reference(&root, &request)?; arguments = if request.checkout { vec!["switch".into(), "-c".into(), name, reference] } else { @@ -516,19 +593,17 @@ fn write_with_trace(request: GitWriteRequest) -> Result { let name = validated_branch_name(&root, request.name.as_deref())?; - let reference = validated_reference(request.reference.as_deref())?; + let reference = write_request_reference(&root, &request)?; let current = current_branch(&root)?; let current_reference = format!("refs/heads/{current}"); - arguments = if request.reference.as_deref() == Some(current.as_str()) - || request.reference.as_deref() == Some(current_reference.as_str()) - { + arguments = if reference == current || reference == current_reference { vec!["branch".into(), "-m".into(), name] } else { vec!["branch".into(), "-m".into(), reference, name] }; } "deleteBranch" => { - let reference = validated_reference(request.reference.as_deref())?; + let reference = write_request_reference(&root, &request)?; let branch = local_branch_name(&reference)?; if current_branch(&root)?.as_str() == branch { return Err(CoreError::new( @@ -539,7 +614,7 @@ fn write_with_trace(request: GitWriteRequest) -> Result { - let reference = validated_reference(request.reference.as_deref())?; + let reference = write_request_reference(&root, &request)?; if is_current_reference(&root, &reference)? { return Err(CoreError::new( ErrorCode::InvalidRequest, @@ -549,7 +624,7 @@ fn write_with_trace(request: GitWriteRequest) -> Result { - let reference = validated_reference(request.reference.as_deref())?; + let reference = write_request_reference(&root, &request)?; if is_current_reference(&root, &reference)? { return Err(CoreError::new( ErrorCode::InvalidRequest, @@ -575,19 +650,35 @@ fn write_with_trace(request: GitWriteRequest) -> Result return push(&root, request.reference.as_deref()), + "push" => { + let reference = optional_write_request_reference(&root, &request)?; + return push(&root, reference.as_deref()); + } "checkout" => return checkout(&root, request), "checkoutRevision" => { arguments = vec![ @@ -678,10 +769,23 @@ fn capture_git_with_options( arguments: &[String], input: Option, disable_optional_locks: bool, +) -> Result { + capture_git_with_environment(root, arguments, input, disable_optional_locks, &[]) +} + +fn capture_git_with_environment( + root: &str, + arguments: &[String], + input: Option, + disable_optional_locks: bool, + environment: &[(String, String)], ) -> Result { crate::protocol::cancellation::check()?; let mut process = git_process(); - process.args(arguments).current_dir(root); + process + .args(arguments) + .current_dir(root) + .envs(environment.iter().map(|(key, value)| (key, value))); if disable_optional_locks { process.env("GIT_OPTIONAL_LOCKS", "0"); } @@ -783,7 +887,14 @@ pub fn diff(request: GitDiffRequest) -> Result { )); } - if request.reference.is_some() && request.commit.is_some() { + let root = validate_root(&request.root)?; + let reference = typed_reference_range( + &root, + request.git_reference.as_ref(), + request.target_git_reference.as_ref(), + request.reference.as_deref(), + )?; + if reference.is_some() && request.commit.is_some() { return Err(CoreError::new( ErrorCode::InvalidRequest, "Git diff cannot combine a reference and a commit", @@ -801,7 +912,7 @@ pub fn diff(request: GitDiffRequest) -> Result { format!("--unified={}", request.context_lines), commit, ] - } else if let Some(reference) = request.reference { + } else if let Some(reference) = reference { validate_revision(&reference)?; vec![ "diff".to_string(), @@ -1209,7 +1320,14 @@ pub fn commit_files(request: GitCommitFilesRequest) -> Result Result { let root = validate_root(&request.root)?; - validate_revision(&request.reference)?; + let reference = typed_reference_range( + &root, + request.git_reference.as_ref(), + request.target_git_reference.as_ref(), + request.reference.as_deref(), + )? + .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Missing Git reference"))?; + validate_revision(&reference)?; let response = readonly_command(GitCommandRequest { root, arguments: vec![ @@ -1218,7 +1336,7 @@ pub fn comparison(request: GitComparisonRequest) -> Result Result { let root = validate_root(&request.root)?; - let reference = validated_reference(Some(&request.reference))?; + let reference = request_reference( + &root, + request.git_reference.as_ref(), + request.reference.as_deref(), + )?; let dirty = readonly_command(GitCommandRequest { root: root.clone(), @@ -1419,7 +1541,11 @@ pub fn integration_preflight( request: GitIntegrationPreflightRequest, ) -> Result { let root = validate_root(&request.root)?; - let reference = validated_reference(Some(&request.reference))?; + let reference = request_reference( + &root, + request.git_reference.as_ref(), + request.reference.as_deref(), + )?; let shape = match request.operation.as_str() { "merge" => IntegrationShape::MergeBase, "rebase" => IntegrationShape::AnyDirty, @@ -1899,6 +2025,186 @@ fn required_text(value: Option<&str>, label: &str) -> Result } } +#[derive(Clone)] +struct ValidatedGitReference { + full_name: String, + short_name: String, + kind: String, +} + +fn invalid_git_reference() -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git reference") +} + +fn validated_git_reference( + root: &str, + reference: &GitReferenceRequest, +) -> Result { + if reference.full_name.contains(['\0', '\n', '\r']) + || reference.short_name.contains(['\0', '\n', '\r']) + || reference.full_name.chars().any(char::is_whitespace) + || reference.short_name.trim() != reference.short_name + { + return Err(invalid_git_reference()); + } + + let prefix = match reference.kind.as_str() { + "local" => "refs/heads/", + "remote" => "refs/remotes/", + "tag" => "refs/tags/", + _ => return Err(invalid_git_reference()), + }; + let expected_short_name = reference + .full_name + .strip_prefix(prefix) + .filter(|value| !value.is_empty()) + .ok_or_else(invalid_git_reference)?; + if expected_short_name != reference.short_name { + return Err(invalid_git_reference()); + } + + let checked = execute_git_readonly( + root, + &["check-ref-format".into(), reference.full_name.clone()], + None, + )?; + if checked.exit_code != 0 { + return Err(invalid_git_reference()); + } + if reference.kind == "remote" { + let (remote, branch) = reference + .short_name + .split_once('/') + .filter(|(remote, branch)| { + !remote.is_empty() && !branch.is_empty() && *branch != "HEAD" + }) + .ok_or_else(invalid_git_reference)?; + if remote.starts_with('-') || branch.starts_with('-') { + return Err(invalid_git_reference()); + } + } + + Ok(ValidatedGitReference { + full_name: reference.full_name.clone(), + short_name: reference.short_name.clone(), + kind: reference.kind.clone(), + }) +} + +fn legacy_checkout_reference( + root: &str, + reference: &str, + kind: &str, +) -> Result { + let (full_name, short_name) = match kind { + "local" => { + let short_name = reference.strip_prefix("refs/heads/").unwrap_or(reference); + (format!("refs/heads/{short_name}"), short_name.to_string()) + } + "remote" => { + let short_name = reference + .strip_prefix("refs/remotes/") + .ok_or_else(invalid_git_reference)?; + (reference.to_string(), short_name.to_string()) + } + "tag" => { + let short_name = reference.strip_prefix("refs/tags/").unwrap_or(reference); + (format!("refs/tags/{short_name}"), short_name.to_string()) + } + _ => return Err(invalid_git_reference()), + }; + validated_git_reference( + root, + &GitReferenceRequest { + full_name, + short_name, + kind: kind.to_string(), + }, + ) +} + +fn optional_write_request_reference( + root: &str, + request: &GitWriteRequest, +) -> Result, CoreError> { + if let Some(reference) = request.git_reference.as_ref() { + return validated_git_reference(root, reference).map(|value| Some(value.full_name)); + } + request + .reference + .as_deref() + .map(|reference| validated_reference(Some(reference))) + .transpose() +} + +pub(super) fn write_request_reference( + root: &str, + request: &GitWriteRequest, +) -> Result { + optional_write_request_reference(root, request)? + .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Missing Git reference")) +} + +fn checkout_request_reference( + root: &str, + request: &GitWriteRequest, +) -> Result { + if let Some(reference) = request.git_reference.as_ref() { + return validated_git_reference(root, reference); + } + let reference = validated_reference(request.reference.as_deref())?; + let kind = required_text(request.reference_kind.as_deref(), "reference kind")?; + legacy_checkout_reference(root, &reference, &kind) +} + +fn request_reference( + root: &str, + typed: Option<&GitReferenceRequest>, + legacy: Option<&str>, +) -> Result { + if let Some(reference) = typed { + return validated_git_reference(root, reference).map(|value| value.full_name); + } + validated_reference(legacy) +} + +fn typed_reference_range( + root: &str, + base: Option<&GitReferenceRequest>, + target: Option<&GitReferenceRequest>, + legacy: Option<&str>, +) -> Result, CoreError> { + match (base, target) { + (Some(base), Some(target)) => { + if legacy.is_some() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git comparison cannot combine typed and legacy references", + )); + } + let base = validated_git_reference(root, base)?; + let target = validated_git_reference(root, target)?; + Ok(Some(format!("{}..{}", base.full_name, target.full_name))) + } + (None, Some(_)) => Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git comparison target requires a base reference", + )), + (Some(base), None) => { + if legacy.is_some() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git comparison cannot combine typed and legacy references", + )); + } + Ok(Some(validated_git_reference(root, base)?.full_name)) + } + (None, None) => legacy + .map(|value| validated_reference(Some(value))) + .transpose(), + } +} + fn validate_paths(paths: &[String]) -> Result, CoreError> { if paths.is_empty() || paths.iter().any(|path| !is_safe_pathspec(path)) { return Err(CoreError::new( @@ -1909,6 +2215,599 @@ fn validate_paths(paths: &[String]) -> Result, CoreError> { Ok(paths.to_vec()) } +/// Chooses whether ignore patterns belong to the shared repository file or the +/// current checkout's local Git metadata. +enum GitIgnoreTarget { + Repository, + LocalExclude, +} + +fn append_git_ignore_patterns( + root: &str, + paths: &[String], + target: GitIgnoreTarget, +) -> Result { + let patterns = git_ignore_patterns(paths)?; + let target_path = match target { + GitIgnoreTarget::Repository => repository_root(root)?.join(".gitignore"), + GitIgnoreTarget::LocalExclude => git_path(root, "info/exclude")?, + }; + let existing = match std::fs::read(&target_path) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), + Err(error) => return Err(git_ignore_io_error("read", error)), + }; + let existing_text = String::from_utf8_lossy(&existing); + let additions = patterns + .into_iter() + .filter(|pattern| !existing_text.lines().any(|line| line == pattern)) + .collect::>(); + if additions.is_empty() { + return Ok(successful_git_result()); + } + + if let Some(parent) = target_path.parent() { + std::fs::create_dir_all(parent).map_err(|error| git_ignore_io_error("create", error))?; + } + let mut appended = String::new(); + if !existing.is_empty() && !existing.ends_with(b"\n") { + appended.push('\n'); + } + for pattern in additions { + appended.push_str(&pattern); + appended.push('\n'); + } + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(target_path) + .map_err(|error| git_ignore_io_error("open", error))?; + file.write_all(appended.as_bytes()) + .map_err(|error| git_ignore_io_error("write", error))?; + Ok(successful_git_result()) +} + +fn git_ignore_patterns(paths: &[String]) -> Result, CoreError> { + let mut patterns = Vec::with_capacity(paths.len()); + for path in paths { + if path.contains(['\0', '\n', '\r']) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git ignore operation contains an invalid path", + )); + } + let normalized = path.replace('\\', "/"); + let is_directory = normalized.ends_with('/'); + let normalized = normalized.trim_end_matches('/'); + if !is_safe_pathspec(normalized) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git ignore operation contains an invalid path", + )); + } + + let mut escaped = String::with_capacity(normalized.len() + 2); + escaped.push('/'); + for character in normalized.chars() { + if matches!(character, '*' | '?' | '[' | ']' | '#' | '!' | ' ') { + escaped.push('\\'); + } + escaped.push(character); + } + if is_directory { + escaped.push('/'); + } + patterns.push(escaped); + } + patterns.sort(); + patterns.dedup(); + if patterns.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git ignore operation contains an invalid path", + )); + } + Ok(patterns) +} + +fn repository_root(root: &str) -> Result { + git_resolved_path(root, &["rev-parse", "--show-toplevel"], "repository root") +} + +fn git_path(root: &str, path: &str) -> Result { + git_resolved_path( + root, + &["rev-parse", "--path-format=absolute", "--git-path", path], + "Git metadata path", + ) +} + +fn git_resolved_path(root: &str, arguments: &[&str], label: &str) -> Result { + let arguments = arguments + .iter() + .map(|value| value.to_string()) + .collect::>(); + let response = execute_git_readonly(root, &arguments, None)?; + if response.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + format!("Could not resolve {label}"), + ) + .with_details(response.output)); + } + let path = response.output.trim(); + if path.is_empty() { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + format!("Could not resolve {label}"), + )); + } + Ok(PathBuf::from(path)) +} + +fn git_ignore_io_error(action: &str, error: std::io::Error) -> CoreError { + let code = if error.kind() == std::io::ErrorKind::PermissionDenied { + ErrorCode::PermissionDenied + } else { + ErrorCode::Unknown + }; + CoreError::new(code, format!("Could not {action} Git ignore file")) + .with_details(error.to_string()) +} + +fn successful_git_result() -> GitCommandResponse { + GitCommandResponse { + arguments: Vec::new(), + output: String::new(), + stdout: String::new(), + stderr: String::new(), + exit_code: 0, + invocations: Vec::new(), + operation_error: None, + stash_restore: None, + } +} + +/// Immutable commit fields needed to rebuild a linear history without invoking +/// an editor or losing author and committer attribution. +#[derive(Clone)] +struct RewriteCommit { + tree: String, + parents: Vec, + author_name: String, + author_email: String, + author_date: String, + committer_name: String, + committer_email: String, + committer_date: String, + message: String, +} + +/// Current local branch context and its first-parent chain, ordered from HEAD +/// toward the root commit. +struct HistoryRewriteContext { + branch_reference: String, + original_head: String, + first_parent_chain: Vec, + published_commits: HashSet, +} + +fn edit_commit_message( + root: &str, + revision: &str, + message: &str, +) -> Result { + let context = history_rewrite_context(root)?; + let target = resolve_commit_revision(root, revision)?; + let target_index = history_commit_index(&context, &target)?; + let commits = checked_rewrite_range(root, &context, target_index)?; + let mut parent = commits[0].parents.first().cloned(); + + for (index, commit) in commits.iter().enumerate() { + let commit_message = if index == 0 { message } else { &commit.message }; + parent = Some(write_commit_tree( + root, + commit, + parent.as_deref(), + commit_message, + )?); + } + + update_history_reference( + root, + &context, + parent.as_deref().expect("rewrite range contains a commit"), + "lithe: edit commit message", + ) +} + +fn delete_commit(root: &str, revision: &str) -> Result { + let context = history_rewrite_context(root)?; + let target = resolve_commit_revision(root, revision)?; + let target_index = history_commit_index(&context, &target)?; + let commits = checked_rewrite_range(root, &context, target_index)?; + let target_commit = &commits[0]; + let parent = target_commit.parents.first().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "The root commit cannot be deleted", + ) + })?; + + if target == context.original_head { + return execute_git( + root, + &["reset".into(), "--hard".into(), parent.clone()], + None, + ); + } + + execute_git( + root, + &[ + "-c".into(), + "core.editor=true".into(), + "rebase".into(), + "--onto".into(), + parent.clone(), + target, + ], + None, + ) +} + +fn squash_commits( + root: &str, + revisions: &[String], + message: &str, +) -> Result { + if revisions.len() < 2 { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Select at least two commits to squash", + )); + } + + let context = history_rewrite_context(root)?; + let mut selected = Vec::with_capacity(revisions.len()); + for revision in revisions { + validate_revision(revision)?; + selected.push(resolve_commit_revision(root, revision)?); + } + selected.sort(); + selected.dedup(); + if selected.len() != revisions.len() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Select distinct commits to squash", + )); + } + + let mut selected_indices = selected + .iter() + .map(|commit| history_commit_index(&context, commit)) + .collect::, _>>()?; + selected_indices.sort_unstable(); + let newest_index = selected_indices[0]; + let oldest_index = *selected_indices + .last() + .expect("at least two commits were selected"); + if oldest_index - newest_index + 1 != selected_indices.len() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Only a contiguous range of commits can be squashed", + )); + } + + let commits = checked_rewrite_range(root, &context, oldest_index)?; + let selected_count = oldest_index - newest_index + 1; + let oldest = &commits[0]; + let newest = &commits[selected_count - 1]; + let mut squashed = oldest.clone(); + squashed.tree.clone_from(&newest.tree); + squashed.committer_name.clone_from(&newest.committer_name); + squashed.committer_email.clone_from(&newest.committer_email); + squashed.committer_date.clone_from(&newest.committer_date); + + let mut parent = Some(write_commit_tree( + root, + &squashed, + oldest.parents.first().map(String::as_str), + message, + )?); + for commit in commits.iter().skip(selected_count) { + parent = Some(write_commit_tree( + root, + commit, + parent.as_deref(), + &commit.message, + )?); + } + + update_history_reference( + root, + &context, + parent.as_deref().expect("squash produces a commit"), + "lithe: squash commits", + ) +} + +fn history_rewrite_context(root: &str) -> Result { + let operation = operation_state(GitOperationStateRequest { + root: root.to_string(), + })?; + if !operation.kind.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Finish or abort the current Git operation before rewriting history", + )); + } + + let status = execute_git_readonly( + root, + &[ + "status".into(), + "--porcelain=v1".into(), + "--untracked-files=all".into(), + ], + None, + )?; + if status.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git status failed") + .with_details(status.output), + ); + } + if !status.output.trim().is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Commit history can only be rewritten with a clean working tree", + )); + } + + let branch = execute_git_readonly( + root, + &["symbolic-ref".into(), "--quiet".into(), "HEAD".into()], + None, + )?; + let branch_reference = branch.output.trim(); + if branch.exit_code != 0 || !branch_reference.starts_with("refs/heads/") { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Commit history can only be rewritten on a checked out local branch", + )); + } + + let chain = execute_git_readonly( + root, + &["rev-list".into(), "--first-parent".into(), "HEAD".into()], + None, + )?; + if chain.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not read the current branch history", + ) + .with_details(chain.output)); + } + let first_parent_chain = chain + .output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(String::from) + .collect::>(); + let original_head = first_parent_chain.first().cloned().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "The current branch does not contain any commits", + ) + })?; + + let remote_commits = + execute_git_readonly(root, &["rev-list".into(), "--remotes".into()], None)?; + if remote_commits.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not inspect remote Git history", + ) + .with_details(remote_commits.output)); + } + + Ok(HistoryRewriteContext { + branch_reference: branch_reference.to_string(), + original_head, + first_parent_chain, + published_commits: remote_commits + .output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(String::from) + .collect(), + }) +} + +fn resolve_commit_revision(root: &str, revision: &str) -> Result { + let response = execute_git_readonly( + root, + &[ + "rev-parse".into(), + "--verify".into(), + "--quiet".into(), + "--end-of-options".into(), + format!("{revision}^{{commit}}"), + ], + None, + )?; + let commit = response.output.trim(); + if response.exit_code != 0 || commit.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The selected Git commit does not exist", + )); + } + Ok(commit.to_string()) +} + +fn history_commit_index(context: &HistoryRewriteContext, commit: &str) -> Result { + context + .first_parent_chain + .iter() + .position(|candidate| candidate == commit) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Only commits on the current branch first-parent history can be rewritten", + ) + }) +} + +fn checked_rewrite_range( + root: &str, + context: &HistoryRewriteContext, + oldest_index: usize, +) -> Result, CoreError> { + let hashes = &context.first_parent_chain[..=oldest_index]; + if hashes + .iter() + .any(|commit| context.published_commits.contains(commit)) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Commits published to a remote cannot be rewritten", + )); + } + + let mut commits = hashes + .iter() + .rev() + .map(|hash| read_rewrite_commit(root, hash)) + .collect::, _>>()?; + if commits.iter().any(|commit| commit.parents.len() > 1) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "A history range containing merge commits cannot be rewritten", + )); + } + commits.shrink_to_fit(); + Ok(commits) +} + +fn read_rewrite_commit(root: &str, hash: &str) -> Result { + let format = "%T%x00%P%x00%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%B%x00"; + let response = execute_git_readonly( + root, + &[ + "show".into(), + "--no-patch".into(), + "--no-show-signature".into(), + format!("--format={format}"), + hash.to_string(), + ], + None, + )?; + if response.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not read a commit selected for history rewrite", + ) + .with_details(response.output)); + } + + let output = response + .output + .strip_suffix("\0\n") + .or_else(|| response.output.strip_suffix('\0')) + .unwrap_or(&response.output); + let fields = output.splitn(9, '\0').collect::>(); + if fields.len() != 9 { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Could not decode a commit selected for history rewrite", + )); + } + Ok(RewriteCommit { + tree: fields[0].to_string(), + parents: fields[1].split_whitespace().map(String::from).collect(), + author_name: fields[2].to_string(), + author_email: fields[3].to_string(), + author_date: fields[4].to_string(), + committer_name: fields[5].to_string(), + committer_email: fields[6].to_string(), + committer_date: fields[7].to_string(), + message: fields[8].to_string(), + }) +} + +fn write_commit_tree( + root: &str, + commit: &RewriteCommit, + parent: Option<&str>, + message: &str, +) -> Result { + let mut arguments = vec![ + "-c".into(), + "commit.gpgSign=false".into(), + "commit-tree".into(), + commit.tree.clone(), + ]; + if let Some(parent) = parent { + arguments.extend(["-p".into(), parent.to_string()]); + } + arguments.extend(["-F".into(), "-".into()]); + let environment = vec![ + ("GIT_AUTHOR_NAME".into(), commit.author_name.clone()), + ("GIT_AUTHOR_EMAIL".into(), commit.author_email.clone()), + ("GIT_AUTHOR_DATE".into(), commit.author_date.clone()), + ("GIT_COMMITTER_NAME".into(), commit.committer_name.clone()), + ("GIT_COMMITTER_EMAIL".into(), commit.committer_email.clone()), + ("GIT_COMMITTER_DATE".into(), commit.committer_date.clone()), + ]; + let output = capture_git_with_environment( + root, + &arguments, + Some(message.to_string()), + false, + &environment, + )?; + if output.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not rebuild Git commit history", + ) + .with_details(output.into_command_response(&arguments).output)); + } + let hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if hash.is_empty() { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Git did not return a rebuilt commit identifier", + )); + } + Ok(hash) +} + +fn update_history_reference( + root: &str, + context: &HistoryRewriteContext, + new_head: &str, + reflog_message: &str, +) -> Result { + execute_git( + root, + &[ + "update-ref".into(), + "-m".into(), + reflog_message.into(), + context.branch_reference.clone(), + new_head.into(), + context.original_head.clone(), + ], + None, + ) +} + fn validated_revision(value: Option<&str>) -> Result { let value = required_text(value, "revision")?; validate_revision(&value)?; @@ -2024,29 +2923,29 @@ fn failed_git_result(error: CoreError) -> GitCommandResponse { } } -/// Discards both index and working-tree content for a conflict-dialog rollback. +/// Discards both index and working-tree content for an explicitly confirmed rollback. /// The normal `discard` operation deliberately preserves staged content, while -/// this explicit operation is destructive for the whole path and therefore only -/// used after the UI's second confirmation. +/// this explicit operation is destructive for the whole path. fn discard_all(root: &str, paths: &[String]) -> Result { let mut tracked = Vec::new(); let mut untracked = Vec::new(); - let mut status_arguments = vec![ + let status_arguments = vec![ "status".to_string(), - "--porcelain".to_string(), + "--porcelain=v1".to_string(), + "-z".to_string(), "--untracked-files=all".to_string(), - "--".to_string(), ]; - status_arguments.extend(paths.iter().cloned()); let status = execute_git(root, &status_arguments, None)?; if status.exit_code != 0 { return Ok(status); } + let untracked_paths = status + .output + .split('\0') + .filter_map(|record| record.strip_prefix("?? ")) + .collect::>(); for path in paths { - let is_untracked = status.output.lines().any(|line| { - (line.starts_with("??") || line.starts_with("!!")) && line[3..].trim() == path - }); - if is_untracked { + if untracked_paths.contains(path.as_str()) { untracked.push(path.clone()); } else { tracked.push(path.clone()); @@ -2055,12 +2954,25 @@ fn discard_all(root: &str, paths: &[String]) -> Result(); + let restored = execute_git(root, &arguments, Some(pathspec_input))?; + if restored.exit_code != 0 { + return Ok(restored); } + final_response = restored; } if !untracked.is_empty() { let mut arguments = vec![ @@ -2350,30 +3262,45 @@ pub(super) fn switch_reference( root: &str, request: &GitWriteRequest, ) -> Result { - let reference = validated_reference(request.reference.as_deref())?; + let reference = checkout_request_reference(root, request)?; + switch_validated_reference(root, &reference, request.force) +} + +fn switch_validated_reference( + root: &str, + reference: &ValidatedGitReference, + force: bool, +) -> Result { let mut base: Vec = vec!["switch".into()]; - if request.force { + if force { base.push("--discard-changes".into()); } - match request.reference_kind.as_deref() { - Some("local") => { + match reference.kind.as_str() { + "local" => { + if current_branch(root)? == reference.short_name { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The current branch is already checked out", + )); + } // `git switch` rejects fully qualified refs ("refs/heads/foo"), so pass the // short branch name. Tags still need the full ref for the --detach form. - let branch = local_branch_name(&reference)?; - base.push(branch); + base.push(reference.short_name.clone()); execute_git(root, &base, None) } - Some("tag") => { + "tag" => { base.push("--detach".into()); - base.push(reference); + base.push(reference.full_name.clone()); execute_git(root, &base, None) } - Some("remote") => { - let (_, local_name) = mutations::remote_branch_components(root, &reference)?; - if !is_safe_pathspec(&local_name) { + "remote" => { + let (_, local_name) = reference.short_name.split_once('/').ok_or_else(|| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid remote branch name") + })?; + if current_branch(root)? == local_name { return Err(CoreError::new( ErrorCode::InvalidRequest, - "Invalid remote branch name", + "The current branch is already checked out", )); } let local_ref = format!("refs/heads/{local_name}"); @@ -2393,7 +3320,7 @@ pub(super) fn switch_reference( base.push("--track".into()); base.push("-c".into()); base.push(local_name.to_string()); - base.push(reference); + base.push(reference.full_name.clone()); } execute_git(root, &base, None) } diff --git a/rust/lithe-core/src/git/mutations.rs b/rust/lithe-core/src/git/mutations.rs index f0d7e5063..db0472786 100644 --- a/rust/lithe-core/src/git/mutations.rs +++ b/rust/lithe-core/src/git/mutations.rs @@ -4,7 +4,7 @@ use super::{capture_git_with_options, is_safe_pathspec}; use crate::protocol::{CoreError, ErrorCode}; use super::{ - current_branch, execute_git, switch_reference, validated_reference, GitCommandResponse, + current_branch, execute_git, switch_reference, write_request_reference, GitCommandResponse, GitWriteRequest, }; @@ -13,14 +13,19 @@ pub(super) fn checkout_and_rebase( root: &str, request: GitWriteRequest, ) -> Result { - if !matches!(request.reference_kind.as_deref(), Some("local" | "remote")) { + let reference_kind = request + .git_reference + .as_ref() + .map(|reference| reference.kind.as_str()) + .or(request.reference_kind.as_deref()); + if !matches!(reference_kind, Some("local" | "remote")) { return Err(CoreError::new( ErrorCode::InvalidRequest, "Checkout and rebase requires a local or remote branch", )); } let original_branch = current_branch(root)?; - let reference = validated_reference(request.reference.as_deref())?; + let reference = write_request_reference(root, &request)?; if reference == original_branch || reference == format!("refs/heads/{original_branch}") { return Err(CoreError::new( ErrorCode::InvalidRequest, diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index e20d535fe..bda859307 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -180,6 +180,333 @@ fn git_command_returns_separate_process_streams_and_combined_output() { fs::remove_dir_all(root).expect("temporary workspace should be removable"); } +#[test] +fn git_write_commits_only_selected_paths_and_keeps_other_index_entries() { + let root = temporary_root("git-write-selected-commit"); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + fs::write(root.join("selected.txt"), "initial\n").expect("file should be writable"); + fs::write(root.join("other.txt"), "initial\n").expect("file should be writable"); + assert!(run(&["add", "--all"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + + fs::write(root.join("selected.txt"), "selected change\n").expect("file should be writable"); + fs::write(root.join("other.txt"), "other staged change\n").expect("file should be writable"); + fs::write(root.join("new.txt"), "selected untracked\n").expect("file should be writable"); + assert!(run(&["add", "other.txt"]).status.success()); + + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "selected-commit", + "command": "git.write", + "payload": { + "root": root, + "operation": "commit", + "message": "selected paths", + "paths": ["selected.txt", "new.txt"] + } + })) + .expect("selected commit request should encode"), + )) + .expect("selected commit response should be JSON"); + assert_eq!(response["ok"], true, "{response:?}"); + + let show = run(&["show", "--pretty=format:", "--name-only", "HEAD"]); + let committed_paths = String::from_utf8_lossy(&show.stdout) + .lines() + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect::>(); + assert_eq!(committed_paths, vec!["new.txt", "selected.txt"]); + assert_eq!( + String::from_utf8_lossy(&run(&["diff", "--cached", "--name-only"]).stdout).trim(), + "other.txt" + ); + assert_eq!( + String::from_utf8_lossy(&run(&["show", "HEAD:selected.txt"]).stdout), + "selected change\n" + ); + assert_eq!( + String::from_utf8_lossy(&run(&["show", "HEAD:new.txt"]).stdout), + "selected untracked\n" + ); + + fs::remove_dir_all(root).expect("temporary workspace should be removable"); +} + +#[test] +fn git_write_appends_shared_and_local_ignore_patterns_without_duplicates() { + let root = temporary_root("git-write-ignore"); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + fs::write(root.join(".gitignore"), "# existing").expect("gitignore should be writable"); + + let request = |operation: &str, paths: Value| -> Value { + serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": operation, + "command": "git.write", + "payload": {"root": root, "operation": operation, "paths": paths} + })) + .expect("ignore request should encode"), + )) + .expect("ignore response should be JSON") + }; + + let shared_paths = serde_json::json!(["build output/", "reports/file[1].txt"]); + let shared = request("ignore", shared_paths.clone()); + assert_eq!(shared["ok"], true, "{shared:?}"); + assert_eq!(request("ignore", shared_paths)["ok"], true); + assert_eq!( + fs::read_to_string(root.join(".gitignore")).expect("gitignore should be readable"), + "# existing\n/build\\ output/\n/reports/file\\[1\\].txt\n" + ); + + fs::create_dir_all(root.join("cache")).expect("excluded directory should be creatable"); + fs::create_dir_all(root.join("unselected")).expect("unselected directory should be creatable"); + fs::write(root.join("cache/data.txt"), "excluded\n").expect("excluded file should be writable"); + fs::write(root.join("secret#file.txt"), "excluded\n") + .expect("excluded file should be writable"); + fs::write(root.join("unselected/keep.txt"), "keep\n") + .expect("unselected file should be writable"); + + let local_paths = serde_json::json!(["cache/", "secret#file.txt"]); + let local = request("exclude", local_paths.clone()); + assert_eq!(local["ok"], true, "{local:?}"); + assert_eq!(request("exclude", local_paths)["ok"], true); + assert_eq!( + fs::read_to_string(root.join(".git/info/exclude")) + .expect("local exclude file should be readable") + .lines() + .filter(|line| line.starts_with('/')) + .collect::>(), + vec!["/cache/", "/secret\\#file.txt"] + ); + assert!(root.join("cache/data.txt").is_file()); + assert!(root.join("secret#file.txt").is_file()); + assert!(root.join("unselected/keep.txt").is_file()); + + fs::create_dir_all(root.join("build output")).expect("ignored directory should be creatable"); + fs::create_dir_all(root.join("reports")).expect("ignored directory should be creatable"); + fs::write(root.join("build output/generated.txt"), "ignored\n") + .expect("ignored file should be writable"); + fs::write(root.join("reports/file[1].txt"), "ignored\n") + .expect("ignored file should be writable"); + assert!(run(&["check-ignore", "-q", "build output/generated.txt"]) + .status + .success()); + assert!(run(&["check-ignore", "-q", "reports/file[1].txt"]) + .status + .success()); + assert!(run(&["check-ignore", "-q", "cache/data.txt"]) + .status + .success()); + assert!(run(&["check-ignore", "-q", "secret#file.txt"]) + .status + .success()); + + let invalid = request("ignore", serde_json::json!(["unsafe\npattern"])); + assert_eq!(invalid["ok"], false); + assert_eq!(invalid["error"]["code"], "invalid_request"); + + fs::remove_dir_all(root).expect("temporary workspace should be removable"); +} + +#[test] +fn git_write_edits_a_local_commit_message_and_rebuilds_descendants() { + let root = history_rewrite_repository("git-edit-commit-message"); + commit_history_file(&root, "story.txt", "one\n", "one"); + commit_history_file(&root, "story.txt", "two\n", "two"); + let target = git_text(&root, &["rev-parse", "HEAD"]); + commit_history_file(&root, "story.txt", "three\n", "three"); + let original_tree = git_text(&root, &["rev-parse", "HEAD^{tree}"]); + + let response = history_write( + &root, + serde_json::json!({ + "operation": "editCommitMessage", + "revision": target, + "message": "two edited" + }), + ); + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!(response["data"]["exitCode"], 0, "{response:?}"); + assert_eq!( + git_text(&root, &["log", "--format=%s"]), + "three\ntwo edited\none" + ); + assert_eq!( + git_text(&root, &["rev-parse", "HEAD^{tree}"]), + original_tree + ); + assert_eq!(git_text(&root, &["status", "--porcelain"]), ""); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_write_squashes_a_contiguous_local_commit_range() { + let root = history_rewrite_repository("git-squash-commits"); + commit_history_file(&root, "story.txt", "one\n", "one"); + commit_history_file(&root, "story.txt", "two\n", "two"); + let older = git_text(&root, &["rev-parse", "HEAD"]); + commit_history_file(&root, "story.txt", "three\n", "three"); + let newer = git_text(&root, &["rev-parse", "HEAD"]); + commit_history_file(&root, "tail.txt", "tail\n", "tail"); + let tail = git_text(&root, &["rev-parse", "HEAD"]); + let original_tree = git_text(&root, &["rev-parse", "HEAD^{tree}"]); + + let rejected = history_write( + &root, + serde_json::json!({ + "operation": "squashCommits", + "revisions": [tail, older], + "message": "must be rejected" + }), + ); + assert_eq!(rejected["ok"], true, "{rejected:?}"); + assert_eq!( + rejected["data"]["operationError"]["code"], + "invalid_request" + ); + assert!(rejected["data"]["operationError"]["message"] + .as_str() + .expect("error message should be text") + .contains("contiguous")); + + let response = history_write( + &root, + serde_json::json!({ + "operation": "squashCommits", + "revisions": [newer, older], + "message": "two and three" + }), + ); + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!(response["data"]["exitCode"], 0, "{response:?}"); + assert_eq!( + git_text(&root, &["log", "--format=%s"]), + "tail\ntwo and three\none" + ); + assert_eq!(git_text(&root, &["rev-list", "--count", "HEAD"]), "3"); + assert_eq!( + git_text(&root, &["rev-parse", "HEAD^{tree}"]), + original_tree + ); + assert_eq!(git_text(&root, &["status", "--porcelain"]), ""); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_write_deletes_a_local_commit_and_replays_later_changes() { + let root = history_rewrite_repository("git-delete-commit"); + commit_history_file(&root, "base.txt", "base\n", "base"); + commit_history_file(&root, "dropped.txt", "drop\n", "drop this commit"); + let target = git_text(&root, &["rev-parse", "HEAD"]); + commit_history_file(&root, "kept.txt", "keep\n", "keep this commit"); + + let response = history_write( + &root, + serde_json::json!({"operation": "deleteCommit", "revision": target}), + ); + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!(response["data"]["exitCode"], 0, "{response:?}"); + assert_eq!( + git_text(&root, &["log", "--format=%s"]), + "keep this commit\nbase" + ); + assert!(!root.join("dropped.txt").exists()); + assert_eq!( + fs::read_to_string(root.join("kept.txt")).expect("kept file should remain"), + "keep\n" + ); + assert_eq!(git_text(&root, &["status", "--porcelain"]), ""); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_history_rewrite_rejects_commits_reachable_from_remote_refs() { + let root = history_rewrite_repository("git-rewrite-published"); + commit_history_file(&root, "story.txt", "published\n", "published"); + let target = git_text(&root, &["rev-parse", "HEAD"]); + assert!( + history_git(&root, &["update-ref", "refs/remotes/origin/main", "HEAD"]) + .status + .success() + ); + + let response = history_write( + &root, + serde_json::json!({ + "operation": "editCommitMessage", + "revision": target, + "message": "must be rejected" + }), + ); + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!( + response["data"]["operationError"]["code"], + "invalid_request" + ); + assert!(response["data"]["operationError"]["message"] + .as_str() + .expect("error message should be text") + .contains("remote")); + assert_eq!(git_text(&root, &["log", "-1", "--format=%s"]), "published"); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_history_rewrite_rejects_a_dirty_working_tree() { + let root = history_rewrite_repository("git-rewrite-dirty"); + commit_history_file(&root, "story.txt", "clean\n", "clean"); + let target = git_text(&root, &["rev-parse", "HEAD"]); + fs::write(root.join("story.txt"), "dirty\n").expect("test file should be writable"); + + let response = history_write( + &root, + serde_json::json!({ + "operation": "editCommitMessage", + "revision": target, + "message": "must be rejected" + }), + ); + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!( + response["data"]["operationError"]["code"], + "invalid_request" + ); + assert!(response["data"]["operationError"]["message"] + .as_str() + .expect("error message should be text") + .contains("clean working tree")); + assert_eq!(git_text(&root, &["log", "-1", "--format=%s"]), "clean"); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + #[test] fn git_write_validates_and_executes_shared_mutations() { let root = temporary_root("git-write"); @@ -263,8 +590,8 @@ fn git_write_validates_and_executes_shared_mutations() { "initial\n" ); - // Conflict-dialog rollback must discard both sides of a file, including - // a staged edit followed by a working-tree edit. + // A confirmed rollback must discard both sides of a file, including a + // staged edit followed by a working-tree edit. fs::write(root.join("example.txt"), "staged\n").expect("file should be writable"); assert!(run(&["add", "example.txt"]).status.success()); fs::write(root.join("example.txt"), "working\n").expect("file should be writable"); @@ -272,7 +599,14 @@ fn git_write_validates_and_executes_shared_mutations() { assert_eq!(discard_all["ok"], true, "{discard_all:?}"); assert_eq!( discard_all["data"]["arguments"], - serde_json::json!(["checkout", "HEAD", "--", "example.txt"]) + serde_json::json!([ + "restore", + "--source=HEAD", + "--staged", + "--worktree", + "--pathspec-from-file=-", + "--pathspec-file-nul" + ]) ); assert_eq!( discard_all["data"]["invocations"] @@ -281,7 +615,7 @@ fn git_write_validates_and_executes_shared_mutations() { .iter() .map(|invocation| invocation["arguments"][0].as_str().unwrap_or_default()) .collect::>(), - vec!["status", "checkout"] + vec!["status", "restore"] ); assert_eq!( fs::read_to_string(root.join("example.txt")).expect("file should be readable"), @@ -292,6 +626,23 @@ fn git_write_validates_and_executes_shared_mutations() { "" ); + fs::write(root.join("newly-added.txt"), "staged addition\n") + .expect("new file should be writable"); + assert!(run(&["add", "newly-added.txt"]).status.success()); + fs::write(root.join("untracked-all.txt"), "untracked\n") + .expect("untracked file should be writable"); + let discard_added = request( + "discardAll", + serde_json::json!({"paths": ["newly-added.txt", "untracked-all.txt"]}), + ); + assert_eq!(discard_added["ok"], true, "{discard_added:?}"); + assert!(!root.join("newly-added.txt").exists()); + assert!(!root.join("untracked-all.txt").exists()); + assert_eq!( + String::from_utf8_lossy(&run(&["status", "--porcelain"]).stdout), + "" + ); + // An invalid checkout reference is discovered after smart checkout has // already started; the executed stash command must remain visible. let partial_failure = request( @@ -613,6 +964,62 @@ fn git_write_validates_and_executes_shared_mutations() { fs::remove_dir_all(root).expect("temporary repository should be removable"); } +#[test] +fn git_write_rolls_back_large_selected_path_set_without_command_line_overflow() { + let root = temporary_root("git-write-large-rollback"); + fs::create_dir_all(root.join("bulk")).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + + // This path set exceeds the Windows process command-line limit when every + // path is passed as a separate argument. + let paths = (0..240) + .map(|index| format!("bulk/{index:03}_{}.txt", "selected_path_segment_".repeat(6))) + .collect::>(); + for path in &paths { + fs::write(root.join(path), "initial\n").expect("tracked file should be writable"); + } + assert!(run(&["add", "--all"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + for path in &paths { + fs::write(root.join(path), "changed\n").expect("tracked file should be writable"); + } + assert!(run(&["add", "--all"]).status.success()); + + let request = serde_json::json!({ + "id": "large-rollback", + "command": "git.write", + "payload": { + "root": root, + "operation": "discardAll", + "paths": paths + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("rollback request should encode"), + )) + .expect("rollback response should be JSON"); + assert_eq!(response["ok"], true, "{response:?}"); + assert!(run(&["status", "--porcelain"]).stdout.is_empty()); + assert_eq!( + fs::read_to_string(root.join(&paths[0])).expect("tracked file should be readable"), + "initial\n" + ); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + #[test] fn detached_worktree_context_can_publish_a_pull_request_branch() { let repository = temporary_root("detached-pr-repository"); @@ -1237,6 +1644,65 @@ fn git_diff_and_apply_round_trip_a_patch() { fs::remove_dir_all(root).expect("temporary workspace should be removable"); } +#[test] +fn git_typed_two_reference_comparison_preserves_both_identities() { + let root = temporary_root("git-typed-comparison"); + fs::create_dir_all(&root).expect("temporary workspace should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + let main = String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout) + .trim() + .to_string(); + fs::write(root.join("example.txt"), "main\n").expect("file should be writable"); + assert!(run(&["add", "example.txt"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + assert!(run(&["switch", "-qc", "feature"]).status.success()); + fs::write(root.join("example.txt"), "feature\n").expect("file should be writable"); + assert!(run(&["commit", "-qam", "feature"]).status.success()); + assert!(run(&["switch", "-q", &main]).status.success()); + + let typed_references = serde_json::json!({ + "gitReference": { + "fullName": format!("refs/heads/{main}"), + "shortName": main, + "kind": "local" + }, + "targetGitReference": { + "fullName": "refs/heads/feature", + "shortName": "feature", + "kind": "local" + } + }); + for (command, id) in [ + ("git.diff", "typed-diff"), + ("git.comparison", "typed-files"), + ] { + let mut payload = typed_references.clone(); + payload["root"] = serde_json::json!(root); + if command == "git.diff" { + payload["pathspecs"] = serde_json::json!(["."]); + } + let request = serde_json::json!({ "id": id, "command": command, "payload": payload }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("comparison response should be JSON"); + assert_eq!(response["ok"], true, "{response:?}"); + assert!(response["data"].to_string().contains("example.txt")); + } + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + #[test] fn git_history_returns_references_and_commit_graph_fields() { let root = temporary_root("git-history"); @@ -1924,3 +2390,279 @@ fn explicit_pull_resolves_nested_remote_and_branch_names_against_bare_remote() { ); fs::remove_dir_all(root).expect("fixture should be removable"); } + +#[test] +fn git_typed_remote_checkout_rebase_blocks_dirty_tree_before_switching() { + let root = temporary_root("git-checkout-rebase-remote"); + let upstream = root.join("upstream"); + let work = root.join("work"); + fs::create_dir_all(&upstream).expect("temporary workspace should be creatable"); + let git = |directory: &Path, arguments: &[&str]| history_git(directory, arguments); + let identify = |directory: &Path| { + assert!(git(directory, &["config", "core.autocrlf", "false"]) + .status + .success()); + assert!( + git(directory, &["config", "user.email", "test@example.com"]) + .status + .success() + ); + assert!(git(directory, &["config", "user.name", "Lithe Test"]) + .status + .success()); + }; + assert!(git(&upstream, &["init", "-q", "-b", "main"]) + .status + .success()); + identify(&upstream); + fs::write(upstream.join("base.txt"), "base\n").expect("base file should be writable"); + assert!(git(&upstream, &["add", "."]).status.success()); + assert!(git(&upstream, &["commit", "-qm", "base"]).status.success()); + assert!(git(&upstream, &["switch", "-qc", "feature"]) + .status + .success()); + fs::write(upstream.join("feature.txt"), "feature\n").expect("feature file should be writable"); + assert!(git(&upstream, &["add", "."]).status.success()); + assert!(git(&upstream, &["commit", "-qm", "feature"]) + .status + .success()); + assert!(git(&upstream, &["switch", "-q", "main"]).status.success()); + fs::write(upstream.join("main.txt"), "main\n").expect("main file should be writable"); + assert!(git(&upstream, &["add", "."]).status.success()); + assert!(git(&upstream, &["commit", "-qm", "main"]).status.success()); + assert!(git( + &root, + &[ + "clone", + "-q", + "-c", + "core.autocrlf=false", + "-b", + "main", + upstream.to_str().expect("path should be UTF-8"), + "work" + ] + ) + .status + .success()); + identify(&work); + + let write = |operation: &str, extra: Value| -> Value { + let mut payload = serde_json::json!({ + "root": work, + "operation": operation, + "gitReference": { + "fullName": "refs/remotes/origin/feature", + "shortName": "origin/feature", + "kind": "remote" + } + }); + if let Value::Object(fields) = extra { + for (key, value) in fields { + payload[key] = value; + } + } + serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": operation, + "command": "git.write", + "payload": payload + })) + .expect("request should encode"), + )) + .expect("response should decode") + }; + + fs::write(work.join("untracked.txt"), "dirty\n").expect("dirty file should be writable"); + let blocked = write("checkoutAndRebase", serde_json::json!({})); + assert_eq!(blocked["ok"], true, "{blocked}"); + assert_eq!(blocked["data"]["operationError"]["code"], "invalid_request"); + assert_eq!(git_text(&work, &["branch", "--show-current"]), "main"); + + fs::remove_file(work.join("untracked.txt")).expect("dirty file should be removable"); + let completed = write("checkoutAndRebase", serde_json::json!({})); + assert_eq!(completed["ok"], true, "{completed}"); + assert_eq!(completed["data"]["exitCode"], 0, "{completed}"); + assert_eq!(git_text(&work, &["branch", "--show-current"]), "feature"); + assert!( + git(&work, &["merge-base", "--is-ancestor", "main", "feature"]) + .status + .success() + ); + + fs::remove_dir_all(root).expect("Git fixture should be removable"); +} + +#[test] +fn git_explicit_remote_pull_validates_identity_and_strategy() { + let root = temporary_root("git-pull-remote-reference"); + let upstream = root.join("upstream"); + let work = root.join("work"); + fs::create_dir_all(&upstream).expect("temporary workspace should be creatable"); + assert!(history_git(&upstream, &["init", "-q", "-b", "main"]) + .status + .success()); + assert!( + history_git(&upstream, &["config", "user.email", "test@example.com"]) + .status + .success() + ); + assert!( + history_git(&upstream, &["config", "user.name", "Lithe Test"]) + .status + .success() + ); + fs::write(upstream.join("base.txt"), "base\n").expect("base file should be writable"); + assert!(history_git(&upstream, &["add", "."]).status.success()); + assert!(history_git(&upstream, &["commit", "-qm", "base"]) + .status + .success()); + assert!(history_git(&upstream, &["switch", "-qc", "feature"]) + .status + .success()); + fs::write(upstream.join("feature.txt"), "feature\n").expect("feature file should be writable"); + assert!(history_git(&upstream, &["add", "."]).status.success()); + assert!(history_git(&upstream, &["commit", "-qm", "feature"]) + .status + .success()); + assert!(history_git(&upstream, &["switch", "-q", "main"]) + .status + .success()); + fs::write(upstream.join("main.txt"), "main\n").expect("main file should be writable"); + assert!(history_git(&upstream, &["add", "."]).status.success()); + assert!(history_git(&upstream, &["commit", "-qm", "main"]) + .status + .success()); + assert!(history_git( + &root, + &[ + "clone", + "-q", + "-c", + "core.autocrlf=false", + "-b", + "main", + upstream.to_str().expect("path should be UTF-8"), + "work" + ] + ) + .status + .success()); + assert!( + history_git(&work, &["config", "user.email", "test@example.com"]) + .status + .success() + ); + assert!(history_git(&work, &["config", "user.name", "Lithe Test"]) + .status + .success()); + + let pull = |reference: Value, mode: &str| -> Value { + serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "pull-remote", + "command": "git.write", + "payload": { + "root": work, + "operation": "pull", + "gitReference": reference, + "mode": mode + } + })) + .expect("request should encode"), + )) + .expect("response should decode") + }; + let remote_reference = serde_json::json!({ + "fullName": "refs/remotes/origin/feature", + "shortName": "origin/feature", + "kind": "remote" + }); + let merged = pull(remote_reference.clone(), "merge"); + assert_eq!(merged["ok"], true, "{merged}"); + assert_eq!(merged["data"]["exitCode"], 0, "{merged}"); + assert_eq!( + git_text(&work, &["rev-list", "--parents", "-n", "1", "HEAD"]) + .split_whitespace() + .count(), + 3 + ); + + let mismatched = pull( + serde_json::json!({ + "fullName": "refs/remotes/origin/feature", + "shortName": "feature", + "kind": "local" + }), + "rebase", + ); + assert_eq!(mismatched["ok"], false, "{mismatched}"); + assert_eq!(mismatched["error"]["code"], "invalid_request"); + + fs::remove_dir_all(root).expect("Git fixture should be removable"); +} + +fn history_rewrite_repository(label: &str) -> std::path::PathBuf { + let root = temporary_root(label); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + assert!(history_git(&root, &["init", "-q", "-b", "main"]) + .status + .success()); + assert!(history_git(&root, &["config", "core.autocrlf", "false"]) + .status + .success()); + assert!( + history_git(&root, &["config", "user.email", "test@example.com"]) + .status + .success() + ); + assert!(history_git(&root, &["config", "user.name", "Lithe Test"]) + .status + .success()); + root +} + +fn commit_history_file(root: &Path, path: &str, contents: &str, message: &str) { + fs::write(root.join(path), contents).expect("history fixture file should be writable"); + assert!(history_git(root, &["add", "--", path]).status.success()); + assert!(history_git(root, &["commit", "-qm", message]) + .status + .success()); +} + +fn history_write(root: &Path, overrides: Value) -> Value { + let mut payload = serde_json::json!({"root": root}); + if let Value::Object(overrides) = overrides { + for (key, value) in overrides { + payload[key.as_str()] = value; + } + } + serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "history-write", + "command": "git.write", + "payload": payload + })) + .expect("history rewrite request should encode"), + )) + .expect("history rewrite response should be JSON") +} + +fn history_git(root: &Path, arguments: &[&str]) -> std::process::Output { + Command::new("git") + .args(arguments) + .current_dir(root) + .output() + .expect("git should be available") +} + +fn git_text(root: &Path, arguments: &[&str]) -> String { + let output = history_git(root, arguments); + assert!( + output.status.success(), + "git {:?} failed: {}", + arguments, + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_string() +} diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 7cb37cfdf..3ff0a24ac 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -241,13 +241,13 @@ response retains the invocation trace and includes the failure as `operationError`. `git.write` accepts a typed mutation request. Its required `operation` values are -`stage`, `unstage`, `discard`, `discardAll`, `stageAll`, `commit`, `cherryPick`, `revert`, -`reset`, `createBranch`, `publishBranch`, `renameBranch`, `deleteBranch`, `merge`, `rebase`, -`checkoutAndRebase`, -`fetch`, `pull`, `push`, `checkout`, `checkoutRevision`, `clone`, `stashPush`, +`stage`, `unstage`, `discard`, `discardAll`, `stageAll`, `commit`, `ignore`, `exclude`, `cherryPick`, `revert`, +`reset`, `editCommitMessage`, `deleteCommit`, `squashCommits`, `createBranch`, `publishBranch`, +`renameBranch`, `deleteBranch`, `merge`, `rebase`, +`fetch`, `pull`, `push`, `checkout`, `checkoutAndRebase`, `checkoutRevision`, `clone`, `stashPush`, `stashApply`, `stashPop`, `stashDrop`, `operationContinue`, `operationAbort`, and `operationSkip`. Optional fields are `paths`, `reference`, `referenceKind`, -`revision`, `name`, `message`, `remote`, `destination`, `mode`, +`gitReference`, `revision`, `revisions`, `name`, `message`, `remote`, `destination`, `mode`, `includeUntracked`, `checkout`, and `amend`. The core validates pathspecs, revisions, branch names, references, reset modes, @@ -276,16 +276,43 @@ and checks out that branch at a detached HEAD when needed, then pushes it with an upstream. If the push fails, the local branch is intentionally retained so the user can fix credentials or connectivity and retry without losing commits. -`checkoutAndRebase` accepts a complete local or remote `reference` plus its -`referenceKind`. Core records the current local branch, rejects any dirty -working tree before switching, checks out the selected branch, and rebases it -onto the original branch. Tags and the current local branch are rejected. - -`pull` without a reference continues to use the current branch's configured -upstream. When `reference` is present, it must be a complete -`refs/remotes//` reference with `referenceKind: "remote"`; -Core safely splits it into structured remote and branch arguments and applies -the requested `ffOnly`, `merge`, or `rebase` strategy. +New reference-based workflows send `gitReference` as `{ "fullName": string, +"shortName": string, "kind": "local" | "remote" | "tag" }`. Core verifies +that all three fields describe the same namespace and validates the full ref +with Git. The legacy `reference` and `referenceKind` fields remain accepted for +existing platform calls. `checkoutAndRebase` requires a local or remote branch +reference and a completely clean worktree; Core records the current local +branch before switching and rebases the checked-out branch onto that original +branch. A dirty tree or detached HEAD is rejected before checkout begins. + +`pull` without an explicit reference retains current-upstream behavior. An +explicit remote reference may use either the preferred `gitReference` shape or +the legacy `reference` plus `referenceKind: "remote"` fields. Core validates and +safely splits `refs/remotes//` against configured remote names, +then invokes pull with the explicit remote and branch using `mode` `ffOnly`, +`merge`, or `rebase`. Platforms must not parse the remote reference or construct +these Git arguments themselves. + +When `commit` includes `paths`, Core stages the complete working-tree state of +those paths, including untracked files and deletions, then commits only those +paths. Other paths already present in the index remain staged and are not part +of the new commit. A `commit` request without `paths` retains the legacy behavior +of committing the existing index. `ignore` appends root-anchored patterns to the +repository's top-level `.gitignore`; `exclude` appends the same patterns to the +worktree-aware Git metadata path for `info/exclude`. Both ignore operations +preserve existing content, escape Git pattern characters, de-duplicate rules, +and interpret a trailing `/` as a directory rule. + +`editCommitMessage` rebuilds the selected commit and its later first-parent +descendants with the new `message`. `squashCommits` requires at least two +distinct, contiguous `revisions`, uses the newest selected tree, and rebuilds +later descendants. Both preserve commit author and committer attribution and +atomically update the checked-out branch reference. `deleteCommit` drops a +non-root commit and replays later commits; deleting HEAD resets to its parent. +All three operations reject a dirty worktree, detached HEAD, an active Git +operation, a target outside the current branch's first-parent chain, a rewrite +range containing a merge commit, or any rewritten commit reachable from +`refs/remotes`. `operationContinue`, `operationAbort`, and `operationSkip` inspect Git metadata to select the active merge, rebase, cherry-pick, or revert instead of accepting @@ -294,7 +321,8 @@ remain, and skip is supported only for a rebase. All three return the normal Git process result when Git is invoked; an absent or unsupported operation state uses the `invalid_request` envelope. -`git.checkoutPreflight` accepts `{ "root": string, "reference": string }` and +`git.checkoutPreflight` accepts `{ "root": string, "reference": string }` or +the preferred `{ "root": string, "gitReference": GitReference }` shape and returns `{ "blockingPaths": string[] }`. The sorted, de-duplicated result contains tracked paths that are both locally modified and different between HEAD and the target, plus untracked paths that the target reference tracks. @@ -306,8 +334,8 @@ string or `null`, numeric `ahead` and `behind` counts, `diverged`, and tracked changes and excludes untracked files. A branch with no configured upstream returns `null`, zero counts, and false for both booleans. -`git.integrationPreflight` accepts `{ "root": string, "reference": string, -"operation": string }`, where `operation` is `merge`, `rebase`, `cherryPick`, +`git.integrationPreflight` accepts either `reference` or `gitReference` with +`root` and `operation`, where `operation` is `merge`, `rebase`, `cherryPick`, or `revert`. It returns sorted, de-duplicated `blockingPaths` and `blocksEntirely`. Merge, cherry-pick, and revert report only dirty tracked paths that overlap files the operation would write. Rebase reports every dirty @@ -326,7 +354,8 @@ conflict marker. A bare nullable, and the progress counters are populated only for a rebase. State is read from Git's own metadata, so operations started outside Lithe are reported. -`git.diff` accepts `root`, `pathspecs`, optional `reference` or `commit`, +`git.diff` accepts `root`, `pathspecs`, optional `reference`, `gitReference`, +`targetGitReference`, or `commit`, `staged`, `untracked`, `contextLines`, and `ignoreAllWhitespace`, and returns `{ "patch": string, "rows": [], "hunks": [] }`. Rows contain one-based `oldLine`/`newLine` values where available, `left`/`right` text, a `kind` (`context`, `changed`, `addition`, @@ -335,6 +364,15 @@ available, `left`/`right` text, a `kind` (`context`, `changed`, `addition`, clients must fall back to `left`. Hunk entries contain their header and the patch text needed for partial apply; rows are not duplicated per hunk, so clients group `rows` by `hunkID` instead. +New reference-tree workflows use `gitReference`; Core validates its full +identity before constructing the diff invocation. When `targetGitReference` is +present, Core validates both complete identities and constructs the two-ref +range. The legacy `reference` field remains available for existing revision and +range comparisons. + +`git.comparison` accepts `root` plus the same `reference` or `gitReference` / +`targetGitReference` forms and returns the deterministically ordered changed +files. Platforms must not construct a two-ref range themselves. `git.apply` accepts `root`, `patch`, and `mode`; supported modes are `stage`, `unstage`, `discard`, `restoreIndex`, `worktree`, `restoreIndexCheck`, and `worktreeCheck`. The two `*Check` modes only test whether the reverse patch diff --git a/shared/fixtures/git/diff.json b/shared/fixtures/git/diff.json index c079c94b3..27bc16ff7 100644 --- a/shared/fixtures/git/diff.json +++ b/shared/fixtures/git/diff.json @@ -1,5 +1,17 @@ { "name": "structured-diff", + "typedComparisonRequest": { + "gitReference": { + "fullName": "refs/heads/main", + "shortName": "main", + "kind": "local" + }, + "targetGitReference": { + "fullName": "refs/remotes/origin/feature", + "shortName": "origin/feature", + "kind": "remote" + } + }, "patch": "diff --git a/Example.java b/Example.java\n--- a/Example.java\n+++ b/Example.java\n@@ -1,3 +1,4 @@\n class Example {\n- return 1;\n+ return 2;\n+ // added\n }\n", "expected": { "rowKinds": ["information", "context", "changed", "addition", "context"], diff --git a/shared/fixtures/git/write.json b/shared/fixtures/git/write.json index 1740d6ba5..4308431b8 100644 --- a/shared/fixtures/git/write.json +++ b/shared/fixtures/git/write.json @@ -29,6 +29,16 @@ "referenceKind": "local" } }, + { + "operation": "checkout", + "payload": { + "gitReference": { + "fullName": "refs/remotes/origin/feature/core", + "shortName": "origin/feature/core", + "kind": "remote" + } + } + }, { "operation": "checkoutAndRebase", "payload": { @@ -36,6 +46,16 @@ "referenceKind": "remote" } }, + { + "operation": "checkoutAndRebase", + "payload": { + "gitReference": { + "fullName": "refs/remotes/origin/feature/core", + "shortName": "origin/feature/core", + "kind": "remote" + } + } + }, { "operation": "pull", "payload": { @@ -44,6 +64,17 @@ "mode": "rebase" } }, + { + "operation": "pull", + "payload": { + "gitReference": { + "fullName": "refs/remotes/origin/feature/core", + "shortName": "origin/feature/core", + "kind": "remote" + }, + "mode": "rebase" + } + }, { "operation": "stashPush", "payload": { @@ -76,6 +107,17 @@ "mode": "merge" }, "errorCode": "invalid_request" + }, + { + "operation": "checkoutAndRebase", + "payload": { + "gitReference": { + "fullName": "refs/remotes/origin/feature/core", + "shortName": "feature/core", + "kind": "local" + } + }, + "errorCode": "invalid_request" } ] } diff --git a/windows/tauri/src-tauri/src/platform.rs b/windows/tauri/src-tauri/src/platform.rs index 09bcb0105..5d02163cb 100644 --- a/windows/tauri/src-tauri/src/platform.rs +++ b/windows/tauri/src-tauri/src/platform.rs @@ -151,9 +151,20 @@ fn translate(command: &str, args: Value) -> Result<(String, Value), String> { "git.diff" } "git_ref_diff" => { - let base = take_text(&mut payload, "baseRef")?; - let target = take_text(&mut payload, "targetRef")?; - payload.insert("reference".into(), json!(format!("{base}..{target}"))); + if payload.contains_key("gitReference") { + payload.remove("baseRef"); + payload.remove("targetRef"); + payload.remove("reference"); + } else { + let base = take_text(&mut payload, "baseRef")?; + let target = take_text(&mut payload, "targetRef")?; + payload.insert("reference".into(), json!(format!("{base}..{target}"))); + } + payload.insert("pathspecs".into(), json!(["."])); + "git.diff" + } + "git_working_tree_ref_diff" => { + preserve_typed_or_legacy_reference(&mut payload, "reference", false)?; payload.insert("pathspecs".into(), json!(["."])); "git.diff" } @@ -175,18 +186,22 @@ fn translate(command: &str, args: Value) -> Result<(String, Value), String> { } "git_create_branch" => { move_field(&mut payload, "branchName", "name"); - if !payload.contains_key("reference") { + if !payload.contains_key("gitReference") && !payload.contains_key("reference") { if let Some(from_branch) = payload.remove("fromBranch") { - let branch = from_branch + let reference = from_branch .as_str() .filter(|value| !value.trim().is_empty()) .map(local_branch_reference) .unwrap_or_else(|| "HEAD".to_string()); - payload.insert("reference".into(), json!(branch)); + payload.insert("reference".into(), json!(reference)); } + } else { + payload.remove("fromBranch"); } payload.insert("operation".into(), json!("createBranch")); - payload.entry("reference").or_insert_with(|| json!("HEAD")); + if !payload.contains_key("gitReference") { + payload.entry("reference").or_insert_with(|| json!("HEAD")); + } "git.write" } "git_delete_branch" => { @@ -195,34 +210,35 @@ fn translate(command: &str, args: Value) -> Result<(String, Value), String> { payload.insert("operation".into(), json!("deleteBranch")); "git.write" } - "git_checkout" => { - let reference = take_reference(&mut payload)?; - let reference_kind = reference_kind(&reference); - payload.insert("reference".into(), json!(reference)); - payload.insert("operation".into(), json!("checkout")); - payload - .entry("referenceKind") - .or_insert_with(|| json!(reference_kind)); - "git.write" - } - "git_checkout_and_rebase" => { - let reference = take_reference(&mut payload)?; - let reference_kind = reference_kind(&reference); - payload.insert("reference".into(), json!(reference)); - payload.insert("operation".into(), json!("checkoutAndRebase")); - payload - .entry("referenceKind") - .or_insert_with(|| json!(reference_kind)); + "git_checkout" | "git_checkout_and_rebase" => { + let typed = payload.contains_key("gitReference"); + preserve_typed_or_legacy_reference(&mut payload, "branchName", true)?; + payload.insert( + "operation".into(), + json!(if command == "git_checkout" { + "checkout" + } else { + "checkoutAndRebase" + }), + ); + if !typed { + let reference = payload + .get("reference") + .and_then(Value::as_str) + .ok_or_else(|| "Windows platform command requires reference".to_string())?; + let kind = reference_kind(reference); + payload + .entry("referenceKind") + .or_insert_with(|| json!(kind)); + } "git.write" } "git_checkout_preflight" => { - let reference = take_reference(&mut payload)?; - payload.insert("reference".into(), json!(reference)); + preserve_typed_or_legacy_reference(&mut payload, "branchName", true)?; "git.checkoutPreflight" } "git_merge" | "git_rebase" => { - let reference = take_reference(&mut payload)?; - payload.insert("reference".into(), json!(reference)); + preserve_typed_or_legacy_reference(&mut payload, "branchName", true)?; payload.insert( "operation".into(), json!(if command == "git_merge" { @@ -234,8 +250,7 @@ fn translate(command: &str, args: Value) -> Result<(String, Value), String> { "git.write" } "git_integration_preflight" => { - let reference = take_reference(&mut payload)?; - payload.insert("reference".into(), json!(reference)); + preserve_typed_or_legacy_reference(&mut payload, "branchName", true)?; "git.integrationPreflight" } "git_operation_state" => "git.operationState", @@ -507,6 +522,40 @@ fn move_field(payload: &mut Map, from: &str, to: &str) { } } +fn preserve_typed_or_legacy_reference( + payload: &mut Map, + legacy_field: &str, + qualify_local: bool, +) -> Result<(), String> { + if payload.contains_key("gitReference") { + payload.remove(legacy_field); + payload.remove("reference"); + return Ok(()); + } + if let Some(reference) = payload.remove("reference") { + let reference = reference + .as_str() + .map(str::to_string) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "Windows platform command requires reference".to_string())?; + payload.insert("reference".into(), json!(reference)); + if legacy_field != "reference" { + payload.remove(legacy_field); + } + return Ok(()); + } + let reference = take_text(payload, legacy_field)?; + payload.insert( + "reference".into(), + json!(if qualify_local { + local_branch_reference(&reference) + } else { + reference + }), + ); + Ok(()) +} + /// Windows callers name local branches by their short form, while the shared /// core requires fully qualified references so branch and tag names cannot /// collide. Only `refs/heads/` counts as already qualified; any other ref @@ -904,6 +953,127 @@ mod tests { ); } + #[test] + fn preserves_typed_remote_references_for_compatibility_commands() { + let reference = json!({ + "fullName": "refs/remotes/origin/feature/checkout", + "shortName": "origin/feature/checkout", + "kind": "remote" + }); + for (compatibility_command, core_command, operation) in [ + ("git_checkout", "git.write", Some("checkout")), + ("git_checkout_preflight", "git.checkoutPreflight", None), + ("git_merge", "git.write", Some("merge")), + ("git_rebase", "git.write", Some("rebase")), + ] { + let (command, payload) = translate( + compatibility_command, + json!({ "repoPath": "C:/work", "gitReference": reference }), + ) + .unwrap(); + assert_eq!(command, core_command); + assert_eq!(payload.get("gitReference"), Some(&reference)); + assert_eq!(payload.get("reference"), None); + assert_eq!(payload.get("referenceKind"), None); + assert_eq!( + payload.get("operation").and_then(|value| value.as_str()), + operation + ); + } + + let (command, payload) = translate( + "git_integration_preflight", + json!({ + "repoPath": "C:/work", + "gitReference": reference, + "operation": "merge" + }), + ) + .unwrap(); + assert_eq!(command, "git.integrationPreflight"); + assert_eq!(payload.get("gitReference"), Some(&reference)); + } + + #[test] + fn translates_working_tree_reference_diff() { + let (command, payload) = translate( + "git_working_tree_ref_diff", + json!({ + "repoPath": "C:/work", + "reference": "refs/remotes/origin/main" + }), + ) + .unwrap(); + + assert_eq!(command, "git.diff"); + assert_eq!( + payload, + json!({ + "root": "C:/work", + "reference": "refs/remotes/origin/main", + "pathspecs": ["."] + }) + ); + } + + #[test] + fn translates_typed_working_tree_reference_diff_without_rewriting_it() { + let reference = json!({ + "fullName": "refs/tags/v1.0.0", + "shortName": "v1.0.0", + "kind": "tag" + }); + let (command, payload) = translate( + "git_working_tree_ref_diff", + json!({ "repoPath": "C:/work", "gitReference": reference }), + ) + .unwrap(); + + assert_eq!(command, "git.diff"); + assert_eq!( + payload, + json!({ + "root": "C:/work", + "gitReference": reference, + "pathspecs": ["."] + }) + ); + } + + #[test] + fn translates_typed_two_reference_diff_without_constructing_a_range() { + let base = json!({ + "fullName": "refs/remotes/origin/main", + "shortName": "origin/main", + "kind": "remote" + }); + let target = json!({ + "fullName": "refs/heads/main", + "shortName": "main", + "kind": "local" + }); + let (command, payload) = translate( + "git_ref_diff", + json!({ + "repoPath": "C:/work", + "gitReference": base, + "targetGitReference": target + }), + ) + .unwrap(); + + assert_eq!(command, "git.diff"); + assert_eq!( + payload, + json!({ + "root": "C:/work", + "gitReference": base, + "targetGitReference": target, + "pathspecs": ["."] + }) + ); + } + #[test] fn rejects_unknown_platform_command() { let error = translate("missing_command", json!({})).unwrap_err(); diff --git a/windows/tauri/src/features/editor/components/code-editor.tsx b/windows/tauri/src/features/editor/components/code-editor.tsx index d1607d463..5d7a2ede3 100644 --- a/windows/tauri/src/features/editor/components/code-editor.tsx +++ b/windows/tauri/src/features/editor/components/code-editor.tsx @@ -70,6 +70,7 @@ interface CodeEditorProps { readOnly?: boolean; breadcrumbProps?: BreadcrumbProps; scrollable?: boolean; + alwaysConsumeMouseWheel?: boolean; backgroundLayer?: ReactNode; onReadonlySurfaceClick?: (position: { line: number; column: number }) => void; highlightMatches?: Array<{ start: number; end: number }>; @@ -140,6 +141,7 @@ const CodeEditor = ({ readOnly = false, breadcrumbProps, scrollable = true, + alwaysConsumeMouseWheel = true, backgroundLayer, onReadonlySurfaceClick, highlightMatches, @@ -676,6 +678,7 @@ const CodeEditor = ({ enableExpensiveServices={enableRichEditorServices} readOnly={readOnly} scrollable={scrollable} + alwaysConsumeMouseWheel={alwaysConsumeMouseWheel} backgroundLayer={backgroundLayer} onReadonlySurfaceClick={onReadonlySurfaceClick} highlightMatches={highlightMatches} diff --git a/windows/tauri/src/features/editor/components/monaco-editor.tsx b/windows/tauri/src/features/editor/components/monaco-editor.tsx index d8d4001f0..e939734f4 100644 --- a/windows/tauri/src/features/editor/components/monaco-editor.tsx +++ b/windows/tauri/src/features/editor/components/monaco-editor.tsx @@ -126,6 +126,7 @@ interface MonacoEditorProps { enableExpensiveServices?: boolean; readOnly?: boolean; scrollable?: boolean; + alwaysConsumeMouseWheel?: boolean; backgroundLayer?: ReactNode; onReadonlySurfaceClick?: (position: { line: number; column: number }) => void; highlightMatches?: Array<{ start: number; end: number }>; @@ -156,6 +157,7 @@ export function MonacoEditor({ enableExpensiveServices = true, readOnly = false, scrollable = true, + alwaysConsumeMouseWheel = true, backgroundLayer, onReadonlySurfaceClick, highlightMatches, @@ -685,7 +687,7 @@ export function MonacoEditor({ vertical: scrollable ? "auto" : "hidden", horizontal: scrollable ? "auto" : "hidden", handleMouseWheel: scrollable, - alwaysConsumeMouseWheel: scrollable, + alwaysConsumeMouseWheel: scrollable && alwaysConsumeMouseWheel, }, }); @@ -1131,6 +1133,7 @@ export function MonacoEditor({ rootFolderPath, workspaceId, scrollable, + alwaysConsumeMouseWheel, scheduleInlineGitBlameRender, selectEntireModel, semanticTokens, @@ -1622,7 +1625,7 @@ export function MonacoEditor({ vertical: scrollable ? "auto" : "hidden", horizontal: scrollable ? "auto" : "hidden", handleMouseWheel: scrollable, - alwaysConsumeMouseWheel: scrollable, + alwaysConsumeMouseWheel: scrollable && alwaysConsumeMouseWheel, }, }); if (container) syncContainedEditorFontOptions(container, fontOptions); @@ -1663,6 +1666,7 @@ export function MonacoEditor({ renderIndentGuides, renderWhitespace, scrollable, + alwaysConsumeMouseWheel, semanticTokens, tabSize, themeId, diff --git a/windows/tauri/src/features/file-explorer/hooks/use-file-tree-presentation.ts b/windows/tauri/src/features/file-explorer/hooks/use-file-tree-presentation.ts new file mode 100644 index 000000000..c8ce24f4f --- /dev/null +++ b/windows/tauri/src/features/file-explorer/hooks/use-file-tree-presentation.ts @@ -0,0 +1,31 @@ +import { useShallow } from "zustand/react/shallow"; +import { useSettingsStore } from "@/features/settings/stores/settings.store"; +import { getFileTreeRowHeight } from "../lib/file-tree-row"; + +export interface FileTreePresentation { + compactFolders: boolean; + indentSize: number; + rowHeight: number; + showIcons: boolean; + showIndentGuides: boolean; +} + +export function useFileTreePresentation(): FileTreePresentation { + const settings = useSettingsStore( + useShallow((state) => ({ + compactFolders: state.settings.compactFoldersInFileTree, + indentSize: state.settings.fileTreeIndentSize, + showIcons: state.settings.showFileIconsInFileTree, + showIndentGuides: state.settings.showIndentGuidesInFileTree, + uiFontSize: state.settings.uiFontSize, + })), + ); + + return { + compactFolders: settings.compactFolders, + indentSize: settings.indentSize, + rowHeight: getFileTreeRowHeight(settings.uiFontSize), + showIcons: settings.showIcons, + showIndentGuides: settings.showIndentGuides, + }; +} diff --git a/windows/tauri/src/features/file-explorer/styles/file-explorer-tree.css b/windows/tauri/src/features/file-explorer/styles/file-explorer-tree.css index a2632d02c..6201a714b 100644 --- a/windows/tauri/src/features/file-explorer/styles/file-explorer-tree.css +++ b/windows/tauri/src/features/file-explorer/styles/file-explorer-tree.css @@ -42,7 +42,7 @@ min-width: 0 !important; } -.file-tree-container .file-tree-item button { +.file-tree-container .file-tree-item > button { box-sizing: border-box !important; border: 1px solid transparent !important; border-radius: var(--file-tree-row-radius) !important; @@ -57,22 +57,27 @@ padding-block: 2px !important; } -.file-tree-container .file-tree-item button:hover { +.file-tree-container .file-tree-item[data-has-action="true"] > button { + width: auto !important; + flex: 1 1 0%; +} + +.file-tree-container .file-tree-item > button:hover { background-color: transparent !important; } -.file-tree-container .file-tree-item button.bg-selected, -.file-tree-container .file-tree-item button.bg-selected:hover { +.file-tree-container .file-tree-item > button.bg-selected, +.file-tree-container .file-tree-item > button.bg-selected:hover { border-color: transparent !important; background-color: var(--file-tree-selected-idle-bg) !important; } -.file-explorer-shell[data-tree-focused="true"] .file-tree-item button.bg-selected, -.file-explorer-shell[data-tree-focused="true"] .file-tree-item button.bg-selected:hover { +.file-explorer-shell[data-tree-focused="true"] .file-tree-item > button.bg-selected, +.file-explorer-shell[data-tree-focused="true"] .file-tree-item > button.bg-selected:hover { background-color: var(--selected) !important; } -.file-tree-container .file-tree-item button:focus-visible { +.file-tree-container .file-tree-item > button:focus-visible { border-color: transparent !important; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 52%, var(--border)); } diff --git a/windows/tauri/src/features/git/api/git-branches-api.test.ts b/windows/tauri/src/features/git/api/git-branches-api.test.ts new file mode 100644 index 000000000..659124ec8 --- /dev/null +++ b/windows/tauri/src/features/git/api/git-branches-api.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import * as gitEvents from "../events/git-events"; + +const invoke = mock(async (command: string): Promise => + command === "git_discover_repo" ? "C:/repo" : null, +); +const emitGitChanged = spyOn(gitEvents, "emitGitChanged"); + +mock.module("@/platform/tauri-core", () => ({ invoke })); + +const { + checkoutGitReference, + checkoutRemoteBranch, + createAndCheckoutBranch, + pushBranch, + renameBranch, + setBranchUpstream, + unsetBranchUpstream, +} = await import("./git-branches-api"); + +beforeEach(() => { + invoke.mockReset(); + invoke.mockImplementation(async (command: string) => + command === "git_discover_repo" ? "C:/repo" : command === "git.checkoutPreflight" ? { blockingPaths: [] } : null, + ); + emitGitChanged.mockClear(); +}); + +describe("Git branch reference mutations", () => { + const remoteReference = { + fullName: "refs/remotes/origin/feature/orders", + shortName: "origin/feature/orders", + kind: "remote" as const, + isCurrent: false, + }; + + test("checks out a remote reference through the typed Core contract", async () => { + await expect( + checkoutRemoteBranch("C:/repo", "refs/remotes/origin/feature/orders"), + ).resolves.toEqual({ success: true, hasChanges: false, message: "" }); + expect(invoke).toHaveBeenCalledWith("git.write", { + repoPath: "C:/repo", + operation: "checkout", + reference: "refs/remotes/origin/feature/orders", + referenceKind: "remote", + }); + }); + + test("checks out a complete remote reference without reducing its identity", async () => { + await expect(checkoutGitReference("C:/repo", remoteReference)).resolves.toEqual({ + success: true, + hasChanges: false, + message: "", + }); + expect(invoke).toHaveBeenCalledWith("git.checkoutPreflight", { + repoPath: "C:/repo", + gitReference: { + fullName: remoteReference.fullName, + shortName: remoteReference.shortName, + kind: remoteReference.kind, + }, + }); + expect(invoke).toHaveBeenCalledWith("git.write", { + repoPath: "C:/repo", + operation: "checkout", + gitReference: { + fullName: remoteReference.fullName, + shortName: remoteReference.shortName, + kind: remoteReference.kind, + }, + }); + }); + + test("creates and checks out a local branch at the selected reference", async () => { + await createAndCheckoutBranch("C:/repo", "feature/local", remoteReference); + expect(invoke).toHaveBeenCalledWith("git.write", { + repoPath: "C:/repo", + operation: "createBranch", + name: "feature/local", + gitReference: { + fullName: remoteReference.fullName, + shortName: remoteReference.shortName, + kind: remoteReference.kind, + }, + checkout: true, + }); + }); + + test("renames and pushes the selected local branch rather than implicit HEAD", async () => { + await renameBranch("C:/repo", "feature/old", "feature/new"); + await pushBranch("C:/repo", "feature/new"); + expect(invoke).toHaveBeenCalledWith("git.write", { + repoPath: "C:/repo", + operation: "renameBranch", + reference: "refs/heads/feature/old", + name: "feature/new", + }); + expect(invoke).toHaveBeenCalledWith("git.write", { + repoPath: "C:/repo", + operation: "push", + reference: "refs/heads/feature/new", + }); + }); + + test("sets and unsets the selected branch upstream", async () => { + await setBranchUpstream("C:/repo", "main", "origin/main"); + await unsetBranchUpstream("C:/repo", "main"); + expect(invoke).toHaveBeenCalledWith("git.command", { + repoPath: "C:/repo", + arguments: ["branch", "--set-upstream-to", "origin/main", "main"], + }); + expect(invoke).toHaveBeenCalledWith("git.command", { + repoPath: "C:/repo", + arguments: ["branch", "--unset-upstream", "main"], + }); + }); +}); diff --git a/windows/tauri/src/features/git/api/git-branches-api.ts b/windows/tauri/src/features/git/api/git-branches-api.ts index deacd3f8e..ab4c77d47 100644 --- a/windows/tauri/src/features/git/api/git-branches-api.ts +++ b/windows/tauri/src/features/git/api/git-branches-api.ts @@ -7,8 +7,9 @@ import { resolveRepositoryPathOrThrow, } from "./git-repo-api"; import type { GitReference } from "../types/git.types"; +import { referencePayload, type GitReferenceInput } from "./git-reference-payload"; -interface CheckoutResult { +export interface CheckoutResult { success: boolean; hasChanges: boolean; message: string; @@ -31,6 +32,9 @@ const blockingChangesMessage = (blockingPaths: string[]): string => { return `Local changes would be overwritten by switching branches: ${listed}${suffix}`; }; +const localBranchReference = (branchName: string): string => + branchName.startsWith("refs/heads/") ? branchName : `refs/heads/${branchName}`; + export const getBranches = async (repoPath: string): Promise => { try { const resolvedRepoPath = await resolveRepositoryPath(repoPath); @@ -52,28 +56,21 @@ export const getBranches = async (repoPath: string): Promise => { export const checkoutBranch = async ( repoPath: string, branchName: string, -): Promise => { - const shortName = branchName.replace(/^refs\/heads\//, ""); - return checkoutReference(repoPath, { - fullName: branchName.startsWith("refs/heads/") ? branchName : `refs/heads/${branchName}`, - shortName, - kind: "local", - isCurrent: false, - }); -}; +): Promise => checkoutReference(repoPath, localBranchReference(branchName), "local"); export const checkoutReference = async ( repoPath: string, - reference: GitReference, + reference: GitReferenceInput, + referenceKind?: "local" | "remote" | "tag", ): Promise => { try { const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); - const preflight = await tauriInvoke("git_checkout_preflight", { + const preflight = await tauriInvoke("git.checkoutPreflight", { repoPath: resolvedRepoPath, - reference: reference.fullName, + ...referencePayload(reference), }); - if (preflight.blocked) { + if (preflight.blockingPaths.length > 0) { return { success: false, hasChanges: true, @@ -81,19 +78,18 @@ export const checkoutReference = async ( }; } - const result = await tauriInvoke("git_checkout", { + await tauriInvoke("git.write", { repoPath: resolvedRepoPath, - reference: reference.fullName, - referenceKind: reference.kind, + operation: "checkout", + ...referencePayload(reference), + ...(typeof reference === "string" && referenceKind ? { referenceKind } : {}), }); - if (result.success) { - emitGitChanged({ - repoPath: resolvedRepoPath, - scopes: ["working-tree", "history", "refs"], - source: "checkout-branch", - }); - } - return result; + emitGitChanged({ + repoPath: resolvedRepoPath, + scopes: ["working-tree", "history", "refs"], + source: "checkout-branch", + }); + return { success: true, hasChanges: false, message: "" }; } catch (error) { console.error("Failed to checkout branch:", error); return { @@ -104,6 +100,16 @@ export const checkoutReference = async ( } }; +export const checkoutRemoteBranch = async ( + repoPath: string, + reference: string, +): Promise => checkoutReference(repoPath, reference, "remote"); + +export const checkoutGitReference = async ( + repoPath: string, + reference: GitReference, +): Promise => checkoutReference(repoPath, reference); + export const createBranch = async ( repoPath: string, branchName: string, @@ -111,14 +117,12 @@ export const createBranch = async ( ): Promise => { try { const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); - await tauriInvoke("git_create_branch", { + const source = typeof from === "string" ? localBranchReference(from) : (from ?? "HEAD"); + await tauriInvoke("git.write", { repoPath: resolvedRepoPath, - branchName, - ...(typeof from === "string" - ? { fromBranch: from } - : from - ? { reference: from.fullName, referenceKind: from.kind } - : {}), + operation: "createBranch", + name: branchName, + ...referencePayload(source), }); emitGitChanged({ repoPath: resolvedRepoPath, @@ -132,6 +136,92 @@ export const createBranch = async ( } }; +export const createAndCheckoutBranch = async ( + repoPath: string, + branchName: string, + reference: GitReferenceInput, +): Promise => { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + await tauriInvoke("git.write", { + repoPath: resolvedRepoPath, + operation: "createBranch", + name: branchName, + ...referencePayload(reference), + checkout: true, + }); + emitGitChanged({ + repoPath: resolvedRepoPath, + scopes: ["working-tree", "history", "refs"], + source: "create-and-checkout-branch", + }); +}; + +export const renameBranch = async ( + repoPath: string, + branchName: string, + newBranchName: string, +): Promise => { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + await tauriInvoke("git.write", { + repoPath: resolvedRepoPath, + operation: "renameBranch", + reference: localBranchReference(branchName), + name: newBranchName, + }); + emitGitChanged({ + repoPath: resolvedRepoPath, + scopes: ["history", "refs"], + source: "rename-branch", + }); +}; + +export const pushBranch = async (repoPath: string, branchName: string): Promise => { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + await tauriInvoke("git.write", { + repoPath: resolvedRepoPath, + operation: "push", + reference: localBranchReference(branchName), + }); + emitGitChanged({ + repoPath: resolvedRepoPath, + scopes: ["history", "refs", "remotes"], + source: "push-branch", + }); +}; + +export const setBranchUpstream = async ( + repoPath: string, + branchName: string, + upstreamShortName: string, +): Promise => { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + await tauriInvoke("git.command", { + repoPath: resolvedRepoPath, + arguments: ["branch", "--set-upstream-to", upstreamShortName, branchName], + }); + emitGitChanged({ + repoPath: resolvedRepoPath, + scopes: ["history", "refs", "remotes"], + source: "set-branch-upstream", + }); +}; + +export const unsetBranchUpstream = async ( + repoPath: string, + branchName: string, +): Promise => { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + await tauriInvoke("git.command", { + repoPath: resolvedRepoPath, + arguments: ["branch", "--unset-upstream", branchName], + }); + emitGitChanged({ + repoPath: resolvedRepoPath, + scopes: ["history", "refs", "remotes"], + source: "unset-branch-upstream", + }); +}; + export const deleteBranch = async (repoPath: string, branchName: string): Promise => { try { const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); diff --git a/windows/tauri/src/features/git/api/git-commits-api.test.ts b/windows/tauri/src/features/git/api/git-commits-api.test.ts new file mode 100644 index 000000000..52b814eab --- /dev/null +++ b/windows/tauri/src/features/git/api/git-commits-api.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +let gitWriteResult = { output: "", exitCode: 0 }; + +const invoke = mock(async (command: string, _args?: unknown): Promise => { + if (command === "git_discover_repo") return "C:/repo"; + if (command === "git.write") return gitWriteResult; + return null; +}); + +mock.module("@/platform/tauri-core", () => ({ invoke })); + +const { + cherryPickCommit, + commitSelectedChanges, + deleteCommit, + editCommitMessage, + resetToCommit, + squashCommits, +} = await import("./git-commits-api"); + +beforeEach(() => { + invoke.mockClear(); + gitWriteResult = { output: "", exitCode: 0 }; +}); + +describe("Git commit history mutations", () => { + test("sends typed edit, delete, squash, reset, and cherry-pick requests", async () => { + await editCommitMessage("C:/repo", "a1", "edited"); + await deleteCommit("C:/repo", "b2"); + await squashCommits("C:/repo", ["c3", "b2"], "squashed"); + await resetToCommit("C:/repo", "a1", "mixed"); + await cherryPickCommit("C:/repo", "d4"); + await commitSelectedChanges("C:/repo", "selected", ["new.txt", "changed.txt"]); + + const writes = invoke.mock.calls.filter(([command]) => command === "git.write"); + expect(writes).toEqual([ + [ + "git.write", + { repoPath: "C:/repo", operation: "editCommitMessage", revision: "a1", message: "edited" }, + ], + ["git.write", { repoPath: "C:/repo", operation: "deleteCommit", revision: "b2" }], + [ + "git.write", + { + repoPath: "C:/repo", + operation: "squashCommits", + revisions: ["c3", "b2"], + message: "squashed", + }, + ], + ["git.write", { repoPath: "C:/repo", operation: "reset", revision: "a1", mode: "--mixed" }], + ["git.write", { repoPath: "C:/repo", operation: "cherryPick", revision: "d4" }], + [ + "git.write", + { + repoPath: "C:/repo", + operation: "commit", + message: "selected", + paths: ["new.txt", "changed.txt"], + }, + ], + ]); + }); + + test("rejects a non-zero Git result instead of reporting success", async () => { + gitWriteResult = { output: "commit failed", exitCode: 1 }; + + await expect(commitSelectedChanges("C:/repo", "selected", ["changed.txt"])).rejects.toThrow( + "commit failed", + ); + }); +}); diff --git a/windows/tauri/src/features/git/api/git-commits-api.ts b/windows/tauri/src/features/git/api/git-commits-api.ts index b9d3f3982..d3feb64a2 100644 --- a/windows/tauri/src/features/git/api/git-commits-api.ts +++ b/windows/tauri/src/features/git/api/git-commits-api.ts @@ -8,6 +8,33 @@ import { resolveRepositoryPathOrThrow, } from "./git-repo-api"; +interface GitWriteResult { + output?: string; + exitCode?: number; +} + +export type GitResetMode = "soft" | "mixed" | "hard"; + +const runHistoryMutation = async ( + repoPath: string, + source: string, + payload: Record, +): Promise => { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + const result = await tauriInvoke("git.write", { + repoPath: resolvedRepoPath, + ...payload, + }); + emitGitChanged({ + repoPath: resolvedRepoPath, + scopes: ["working-tree", "history", "refs"], + source, + }); + if (typeof result?.exitCode === "number" && result.exitCode !== 0) { + throw new Error(result.output?.trim() || "Git history operation failed"); + } +}; + export const commitChanges = async (repoPath: string, message: string): Promise => { try { const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); @@ -24,6 +51,22 @@ export const commitChanges = async (repoPath: string, message: string): Promise< } }; +export const commitSelectedChanges = async ( + repoPath: string, + message: string, + filePaths: string[], +): Promise => { + const uniqueFilePaths = [...new Set(filePaths)]; + if (uniqueFilePaths.length === 0) return false; + + await runHistoryMutation(repoPath, "commit", { + operation: "commit", + message, + paths: uniqueFilePaths, + }); + return true; +}; + export const getGitHistory = async ( repoPath: string, limit = 50, @@ -75,3 +118,48 @@ export const getCommitFiles = async ( return null; } }; + +export const editCommitMessage = ( + repoPath: string, + revision: string, + message: string, +): Promise => + runHistoryMutation(repoPath, "edit-commit-message", { + operation: "editCommitMessage", + revision, + message, + }); + +export const deleteCommit = (repoPath: string, revision: string): Promise => + runHistoryMutation(repoPath, "delete-commit", { + operation: "deleteCommit", + revision, + }); + +export const squashCommits = ( + repoPath: string, + revisions: string[], + message: string, +): Promise => + runHistoryMutation(repoPath, "squash-commits", { + operation: "squashCommits", + revisions, + message, + }); + +export const resetToCommit = ( + repoPath: string, + revision: string, + mode: GitResetMode, +): Promise => + runHistoryMutation(repoPath, "reset-to-commit", { + operation: "reset", + revision, + mode: `--${mode}`, + }); + +export const cherryPickCommit = (repoPath: string, revision: string): Promise => + runHistoryMutation(repoPath, "cherry-pick-commit", { + operation: "cherryPick", + revision, + }); diff --git a/windows/tauri/src/features/git/api/git-reference-payload.ts b/windows/tauri/src/features/git/api/git-reference-payload.ts new file mode 100644 index 000000000..f62eec31b --- /dev/null +++ b/windows/tauri/src/features/git/api/git-reference-payload.ts @@ -0,0 +1,14 @@ +import type { GitReference } from "../types/git.types"; + +export type GitReferenceInput = GitReference | string; + +export const toCoreGitReference = (reference: GitReference) => ({ + fullName: reference.fullName, + shortName: reference.shortName, + kind: reference.kind, +}); + +export const referencePayload = (reference: GitReferenceInput) => + typeof reference === "string" + ? { reference } + : { gitReference: toCoreGitReference(reference) }; diff --git a/windows/tauri/src/features/git/api/git-remotes-api.test.ts b/windows/tauri/src/features/git/api/git-remotes-api.test.ts index 0402cae63..6e42430d9 100644 --- a/windows/tauri/src/features/git/api/git-remotes-api.test.ts +++ b/windows/tauri/src/features/git/api/git-remotes-api.test.ts @@ -15,8 +15,14 @@ mock.module("./git-branches-api", () => ({ getBranches })); mock.module("./git-commits-api", () => ({ getGitHistory })); mock.module("./git-status-api", () => ({ getGitStatus })); -const { executePullChanges, fetchChanges, getGitPullWorkflow, getPullPreflight, pullChanges } = - await import("./git-remotes-api"); +const { + deleteRemoteBranch, + executePullChanges, + fetchChanges, + getGitPullWorkflow, + getPullPreflight, + pullChanges, +} = await import("./git-remotes-api"); beforeEach(() => { invoke.mockReset(); @@ -99,4 +105,28 @@ describe("Git remote Pull API", () => { source: "pull-finished", }); }); + + test("deletes only the selected branch from the selected remote", async () => { + invoke.mockImplementation(async (command: string) => + command === "git_discover_repo" ? "C:/repo" : null, + ); + + await deleteRemoteBranch("C:/repo", "upstream", "feature/orders"); + + expect(invoke).toHaveBeenCalledWith("git.command", { + repoPath: "C:/repo", + arguments: [ + "push", + "--delete", + "--", + "upstream", + "refs/heads/feature/orders", + ], + }); + expect(emitGitChanged).toHaveBeenLastCalledWith({ + repoPath: "C:/repo", + scopes: ["history", "refs", "remotes"], + source: "delete-remote-branch", + }); + }); }); diff --git a/windows/tauri/src/features/git/api/git-remotes-api.ts b/windows/tauri/src/features/git/api/git-remotes-api.ts index 50b4ddb83..8f724fa1c 100644 --- a/windows/tauri/src/features/git/api/git-remotes-api.ts +++ b/windows/tauri/src/features/git/api/git-remotes-api.ts @@ -70,6 +70,23 @@ export const removeRemote = async (repoPath: string, name: string): Promise => { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + await tauriInvoke("git.command", { + repoPath: resolvedRepoPath, + arguments: ["push", "--delete", "--", remote, `refs/heads/${branchName}`], + }); + emitGitChanged({ + repoPath: resolvedRepoPath, + scopes: ["history", "refs", "remotes"], + source: "delete-remote-branch", + }); +}; + export const pushChanges = async ( repoPath: string, branch?: string, diff --git a/windows/tauri/src/features/git/api/git-status-api.test.ts b/windows/tauri/src/features/git/api/git-status-api.test.ts new file mode 100644 index 000000000..6ab7071ce --- /dev/null +++ b/windows/tauri/src/features/git/api/git-status-api.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +const invoke = mock(async (command: string): Promise => + command === "git_discover_repo" ? "C:/repo" : null, +); + +mock.module("@/platform/tauri-core", () => ({ invoke })); + +const { + addPathsToGitignore, + addPathsToLocalGitExclude, + rollbackFilesChanges, + setFilesStaged, +} = await import("./git-status-api"); + +beforeEach(() => { + invoke.mockClear(); +}); + +describe("Git status batch mutations", () => { + const expectSingleGitWrite = () => { + expect(invoke.mock.calls.filter(([command]) => command === "git.write")).toHaveLength(1); + }; + + test("stages a directory selection with one shared Core invocation", async () => { + await expect( + setFilesStaged("C:/repo", ["src/first.ts", "src/second.ts", "src/first.ts"], true), + ).resolves.toBe(true); + + expectSingleGitWrite(); + expect(invoke).toHaveBeenLastCalledWith("git.write", { + repoPath: "C:/repo", + operation: "stage", + paths: ["src/first.ts", "src/second.ts"], + }); + }); + + test("unstages every selected path with one shared Core invocation", async () => { + await expect(setFilesStaged("C:/repo", ["src/first.ts", "src/second.ts"], false)).resolves.toBe( + true, + ); + + expectSingleGitWrite(); + expect(invoke).toHaveBeenLastCalledWith("git.write", { + repoPath: "C:/repo", + operation: "unstage", + paths: ["src/first.ts", "src/second.ts"], + }); + }); + + test("rolls back selected tracked paths with one shared Core invocation", async () => { + await expect( + rollbackFilesChanges("C:/repo", ["src/first.ts", "src/second.ts"]), + ).resolves.toBeUndefined(); + + expectSingleGitWrite(); + expect(invoke).toHaveBeenLastCalledWith("git.write", { + repoPath: "C:/repo", + operation: "discardAll", + paths: ["src/first.ts", "src/second.ts"], + }); + }); + + test("adds selected paths to the repository gitignore", async () => { + await expect(addPathsToGitignore("C:/repo", ["generated/", "local.env"])).resolves.toBe(true); + + expectSingleGitWrite(); + expect(invoke).toHaveBeenLastCalledWith("git.write", { + repoPath: "C:/repo", + operation: "ignore", + paths: ["generated/", "local.env"], + }); + }); + + test("adds selected paths to the local Git exclude file", async () => { + await expect(addPathsToLocalGitExclude("C:/repo", ["generated/"])).resolves.toBe(true); + + expectSingleGitWrite(); + expect(invoke).toHaveBeenLastCalledWith("git.write", { + repoPath: "C:/repo", + operation: "exclude", + paths: ["generated/"], + }); + }); +}); diff --git a/windows/tauri/src/features/git/api/git-status-api.ts b/windows/tauri/src/features/git/api/git-status-api.ts index d8e086b10..658a00bf4 100644 --- a/windows/tauri/src/features/git/api/git-status-api.ts +++ b/windows/tauri/src/features/git/api/git-status-api.ts @@ -1,7 +1,6 @@ import { invoke as tauriInvoke } from "@/platform/tauri-core"; import { emitGitChanged } from "../events/git-events"; import { registerGitCacheInvalidator } from "../runtime/git-cache-registry"; -import { runGitFileOperationBatch } from "../utils/git-operation-batch"; import type { GitHunk, GitStatus } from "../types/git.types"; import { isNotGitRepositoryError, @@ -111,31 +110,26 @@ export const setFilesStaged = async ( repoPath: string, filePaths: string[], staged: boolean, -): Promise> => { - if (filePaths.length === 0) return new Map(); +): Promise => { + const uniqueFilePaths = [...new Set(filePaths)]; + if (uniqueFilePaths.length === 0) return true; try { const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); - const command = staged ? "git_add" : "git_reset"; - const results = await runGitFileOperationBatch(filePaths, async (filePath) => { - await tauriInvoke(command, { repoPath: resolvedRepoPath, filePath }); - return true; + await tauriInvoke("git.write", { + repoPath: resolvedRepoPath, + operation: staged ? "stage" : "unstage", + paths: uniqueFilePaths, }); - - const changedFilePaths = Array.from(results) - .filter(([, success]) => success) - .map(([filePath]) => filePath); - if (changedFilePaths.length > 0) { - emitGitChanged({ - repoPath: resolvedRepoPath, - scopes: ["working-tree"], - source: staged ? "stage-files" : "unstage-files", - }); - } - return results; + emitGitChanged({ + repoPath: resolvedRepoPath, + scopes: ["working-tree"], + source: staged ? "stage-files" : "unstage-files", + }); + return true; } catch (error) { console.error(`Failed to ${staged ? "stage" : "unstage"} files:`, error); - return new Map(filePaths.map((filePath) => [filePath, false])); + return false; } }; @@ -238,6 +232,64 @@ export const discardFileChanges = async (repoPath: string, filePath: string): Pr } }; +export const rollbackFilesChanges = async ( + repoPath: string, + filePaths: string[], +): Promise => { + const uniqueFilePaths = [...new Set(filePaths)]; + if (uniqueFilePaths.length === 0) return; + + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + await tauriInvoke("git.write", { + repoPath: resolvedRepoPath, + operation: "discardAll", + paths: uniqueFilePaths, + }); + emitGitChanged({ + repoPath: resolvedRepoPath, + scopes: ["working-tree"], + source: "rollback-files", + }); +}; + +const addPathsToIgnoreFile = async ( + repoPath: string, + filePaths: string[], + operation: "ignore" | "exclude", +): Promise => { + const uniqueFilePaths = [...new Set(filePaths)]; + if (uniqueFilePaths.length === 0) return true; + + try { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + await tauriInvoke("git.write", { + repoPath: resolvedRepoPath, + operation, + paths: uniqueFilePaths, + }); + emitGitChanged({ + repoPath: resolvedRepoPath, + scopes: ["working-tree"], + source: operation === "ignore" ? "add-to-gitignore" : "add-to-git-exclude", + }); + return true; + } catch (error) { + console.error( + `Failed to add paths to ${operation === "ignore" ? ".gitignore" : ".git/info/exclude"}:`, + error, + ); + return false; + } +}; + +export const addPathsToGitignore = (repoPath: string, filePaths: string[]): Promise => + addPathsToIgnoreFile(repoPath, filePaths, "ignore"); + +export const addPathsToLocalGitExclude = ( + repoPath: string, + filePaths: string[], +): Promise => addPathsToIgnoreFile(repoPath, filePaths, "exclude"); + export const initRepository = async (repoPath: string): Promise => { try { await tauriInvoke("git_init", { repoPath }); diff --git a/windows/tauri/src/features/git/api/git-worktrees-api.test.ts b/windows/tauri/src/features/git/api/git-worktrees-api.test.ts new file mode 100644 index 000000000..3907dc3ed --- /dev/null +++ b/windows/tauri/src/features/git/api/git-worktrees-api.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import * as gitEvents from "../events/git-events"; + +const invoke = mock(async (command: string): Promise => + command === "git_discover_repo" ? "C:/repo" : null, +); +const emitGitChanged = spyOn(gitEvents, "emitGitChanged"); + +mock.module("@/platform/tauri-core", () => ({ invoke })); + +const { addWorktreeFromReference } = await import("./git-worktrees-api"); + +beforeEach(() => { + invoke.mockReset(); + invoke.mockImplementation(async (command: string) => + command === "git_discover_repo" ? "C:/repo" : null, + ); + emitGitChanged.mockClear(); +}); + +describe("Git reference worktrees", () => { + test("creates a worktree branch from the selected remote reference and tracks it", async () => { + await addWorktreeFromReference( + "C:/repo", + "D:/worktrees/orders", + "feature/orders-worktree", + "refs/remotes/origin/feature/orders", + "origin/feature/orders", + ); + + expect(invoke).toHaveBeenCalledWith("git.command", { + repoPath: "C:/repo", + arguments: [ + "worktree", + "add", + "-b", + "feature/orders-worktree", + "--", + "D:/worktrees/orders", + "refs/remotes/origin/feature/orders", + ], + }); + expect(invoke).toHaveBeenCalledWith("git.command", { + repoPath: "C:/repo", + arguments: [ + "branch", + "--set-upstream-to", + "origin/feature/orders", + "feature/orders-worktree", + ], + }); + expect(emitGitChanged).toHaveBeenLastCalledWith({ + repoPath: "C:/repo", + scopes: ["repository", "history", "refs"], + source: "add-reference-worktree", + }); + }); +}); diff --git a/windows/tauri/src/features/git/api/git-worktrees-api.ts b/windows/tauri/src/features/git/api/git-worktrees-api.ts index c68e63182..7df67d37b 100644 --- a/windows/tauri/src/features/git/api/git-worktrees-api.ts +++ b/windows/tauri/src/features/git/api/git-worktrees-api.ts @@ -52,6 +52,31 @@ export const addWorktree = async ( } }; +export const addWorktreeFromReference = async ( + repoPath: string, + path: string, + branchName: string, + reference: string, + upstreamShortName?: string, +): Promise => { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + await tauriInvoke("git.command", { + repoPath: resolvedRepoPath, + arguments: ["worktree", "add", "-b", branchName, "--", path, reference], + }); + if (upstreamShortName) { + await tauriInvoke("git.command", { + repoPath: resolvedRepoPath, + arguments: ["branch", "--set-upstream-to", upstreamShortName, branchName], + }); + } + emitGitChanged({ + repoPath: resolvedRepoPath, + scopes: ["repository", "history", "refs"], + source: "add-reference-worktree", + }); +}; + export const removeWorktree = async ( repoPath: string, path: string, diff --git a/windows/tauri/src/features/git/components/diff/git-diff-editor-stack.tsx b/windows/tauri/src/features/git/components/diff/git-diff-editor-stack.tsx index c0b8686a7..6255113fd 100644 --- a/windows/tauri/src/features/git/components/diff/git-diff-editor-stack.tsx +++ b/windows/tauri/src/features/git/components/diff/git-diff-editor-stack.tsx @@ -186,6 +186,7 @@ function LargeDiffSectionEditor({ showToolbar={false} readOnly={true} scrollable={true} + alwaysConsumeMouseWheel={false} highlightMatches={highlightMatches} currentHighlightIndex={currentSearchMatchIndex} /> @@ -329,7 +330,10 @@ function EmbeddedDiffSectionEditor({ if (viewMode === "split") { return (
-
+
-
+
+
( () => + multiDiff.initiallySelectedFileKey ?? multiDiff.initiallyExpandedFileKey ?? (multiDiff.files[0] ? getMultiDiffSectionKey(multiDiff, multiDiff.files[0], 0) : null), ); @@ -716,6 +725,30 @@ const GitDiffEditorStack = memo(function GitDiffEditorStack({ ); const indexingProgress = multiDiff.indexingProgress; const isIndexingDiffs = Boolean(multiDiff.isLoading); + useEffect(() => { + const scrollContainer = diffStackScrollRef.current; + if (!scrollContainer) return; + + const forwardEmbeddedEditorWheel = (event: WheelEvent) => { + const target = event.target; + if (!(target instanceof Element) || !target.closest("[data-diff-outer-wheel]")) return; + if (event.deltaY === 0 || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return; + + const deltaScale = + event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? scrollContainer.clientHeight : 1; + event.preventDefault(); + event.stopPropagation(); + scrollContainer.scrollBy({ top: event.deltaY * deltaScale }); + }; + + scrollContainer.addEventListener("wheel", forwardEmbeddedEditorWheel, { + capture: true, + passive: false, + }); + return () => { + scrollContainer.removeEventListener("wheel", forwardEmbeddedEditorWheel, { capture: true }); + }; + }, [isIndexingDiffs]); const indexingLabel = indexingProgress ? t("git.indexingWithCount", { label: indexingProgress.label ?? t("git.indexing"), @@ -846,6 +879,12 @@ const GitDiffEditorStack = memo(function GitDiffEditorStack({ }, [isWorkingTree], ); + useEffect(() => { + const initialFileKey = multiDiff.initiallySelectedFileKey; + if (!initialFileKey) return; + + handleSelectFileFromTree(initialFileKey); + }, [handleSelectFileFromTree, multiDiff]); useEffect(() => { const nextKeys = new Set( multiDiff.files.map((diff, index) => getMultiDiffSectionKey(multiDiff, diff, index)), @@ -868,11 +907,17 @@ const GitDiffEditorStack = memo(function GitDiffEditorStack({ setSelectedFileKey((previous) => { if (previous && nextKeys.has(previous)) return previous; return ( + multiDiff.initiallySelectedFileKey ?? multiDiff.initiallyExpandedFileKey ?? (multiDiff.files[0] ? getMultiDiffSectionKey(multiDiff, multiDiff.files[0], 0) : null) ); }); - }, [multiDiff.fileKeys, multiDiff.files, multiDiff.initiallyExpandedFileKey]); + }, [ + multiDiff.fileKeys, + multiDiff.files, + multiDiff.initiallyExpandedFileKey, + multiDiff.initiallySelectedFileKey, + ]); useEffect(() => { if (searchMatches.length === 0) { diff --git a/windows/tauri/src/features/git/components/diff/git-diff-text.tsx b/windows/tauri/src/features/git/components/diff/git-diff-text.tsx index 195b3e6cf..00b6bb119 100644 --- a/windows/tauri/src/features/git/components/diff/git-diff-text.tsx +++ b/windows/tauri/src/features/git/components/diff/git-diff-text.tsx @@ -296,6 +296,7 @@ const TextDiffViewer = memo(
void; @@ -29,7 +56,8 @@ interface GitCommitHistoryProps { interface CommitItemProps { commit: GitCommit; - onViewCommitDiff: (commitHash: string) => void; + onSelect: (event: React.MouseEvent, commit: GitCommit) => void; + onContextMenu: (event: React.MouseEvent, commit: GitCommit) => void; isSelected: boolean; syncState: "local" | "pushed"; repoPath?: string; @@ -60,11 +88,14 @@ function getCommitSearchFields(commit: GitCommit, scope: HistorySearchScope) { } const CommitItem = memo( - ({ commit, onViewCommitDiff, isSelected, syncState, repoPath }: CommitItemProps) => { - const handleCommitClick = useCallback(() => { - onViewCommitDiff(commit.hash); - }, [commit.hash, onViewCommitDiff]); - + ({ + commit, + onSelect, + onContextMenu, + isSelected, + syncState, + repoPath, + }: CommitItemProps) => { const shortHash = commit.hash.substring(0, 7); const avatarUrl = getGitAuthorAvatarUrl(commit); @@ -72,7 +103,9 @@ const CommitItem = memo(
+ +
); }; diff --git a/windows/tauri/src/features/git/components/git-commit-panel.tsx b/windows/tauri/src/features/git/components/git-commit-panel.tsx index 85e7d81cf..96039a617 100644 --- a/windows/tauri/src/features/git/components/git-commit-panel.tsx +++ b/windows/tauri/src/features/git/components/git-commit-panel.tsx @@ -7,7 +7,7 @@ import { SparkleIcon as Sparkles, } from "@/ui/icons"; import type React from "react"; -import { useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { useTranslation } from "@/i18n/locale-provider"; import { Button } from "@/ui/button"; @@ -21,17 +21,18 @@ import { InlineEditError, requestInlineEdit, } from "@/features/editor/services/editor-inline-edit-service"; -import { getFileDiff } from "../api/git-diff-api"; -import { commitChanges, getGitLog } from "../api/git-commits-api"; +import { commitSelectedChanges, getGitLog } from "../api/git-commits-api"; import { getConflictMarkerPaths } from "../api/git-integration-api"; import { pushChanges, type GitRemoteActionResult } from "../api/git-remotes-api"; +import { loadWorkingTreeFileDiff } from "../services/working-tree-file-diff"; import { useGitBlameStore } from "../stores/git-blame.store"; import { useGitStore } from "../stores/git.store"; import type { GitDiff, GitFile } from "../types/git.types"; interface GitCommitPanelProps { - stagedFilesCount: number; - stagedFiles: GitFile[]; + selectedFiles: GitFile[]; + commitMessage: string; + onCommitMessageChange: (message: string) => void; currentBranch?: string; repoPath?: string; ahead?: number; @@ -39,9 +40,10 @@ interface GitCommitPanelProps { onCommitSuccess?: () => void; onPull?: () => Promise | void; isPulling?: boolean; + focusRequest?: number; } -const MAX_STAGED_FILES_FOR_AI_CONTEXT = 120; +const MAX_SELECTED_FILES_FOR_AI_CONTEXT = 120; const MAX_RECENT_COMMITS_FOR_AI_CONTEXT = 24; const MAX_DIFF_FILES_FOR_AI_CONTEXT = 10; const MAX_DIFF_LINES_PER_FILE_FOR_AI_CONTEXT = 80; @@ -70,7 +72,7 @@ const countDiffLines = (diff: GitDiff | null) => { }; const formatDiffExcerpt = (file: GitFile, diff: GitDiff | null): string => { - if (!diff) return `### ${file.path}\n(no staged text diff available)`; + if (!diff) return `### ${file.path}\n(no text diff available)`; if (diff.is_binary || diff.is_image) return `### ${file.path}\n(binary or image change)`; const changedLines: string[] = []; @@ -104,23 +106,23 @@ const truncateContext = (context: string): string => { async function buildCommitMessageContext({ repoPath, currentBranch, - stagedFiles, + selectedFiles, existingDraftHint, }: { repoPath: string; currentBranch?: string; - stagedFiles: GitFile[]; + selectedFiles: GitFile[]; existingDraftHint: string; }): Promise { - const stagedFilesForContext = stagedFiles.slice(0, MAX_STAGED_FILES_FOR_AI_CONTEXT); - const diffFilesForContext = stagedFiles.slice(0, MAX_DIFF_FILES_FOR_AI_CONTEXT); - const [recentCommits, stagedDiffs] = await Promise.all([ + const selectedFilesForContext = selectedFiles.slice(0, MAX_SELECTED_FILES_FOR_AI_CONTEXT); + const diffFilesForContext = selectedFiles.slice(0, MAX_DIFF_FILES_FOR_AI_CONTEXT); + const [recentCommits, selectedDiffs] = await Promise.all([ getGitLog(repoPath, MAX_RECENT_COMMITS_FOR_AI_CONTEXT), - Promise.all(diffFilesForContext.map((file) => getFileDiff(repoPath, file.path, true))), + Promise.all(diffFilesForContext.map((file) => loadWorkingTreeFileDiff(repoPath, file))), ]); - const overflowCount = Math.max(stagedFiles.length - stagedFilesForContext.length, 0); - const diffOverflowCount = Math.max(stagedFiles.length - diffFilesForContext.length, 0); - const totals = stagedDiffs.reduce( + const overflowCount = Math.max(selectedFiles.length - selectedFilesForContext.length, 0); + const diffOverflowCount = Math.max(selectedFiles.length - diffFilesForContext.length, 0); + const totals = selectedDiffs.reduce( (sum, diff) => { const counts = countDiffLines(diff); return { @@ -137,11 +139,11 @@ async function buildCommitMessageContext({ .slice(0, MAX_RECENT_COMMITS_FOR_AI_CONTEXT) .map((message) => `- ${message}`) .join("\n"); - const stagedLines = stagedFilesForContext - .map((file) => `- ${file.status}${file.staged ? " staged" : ""}: ${file.path}`) + const selectedLines = selectedFilesForContext + .map((file) => `- ${file.status}: ${file.path}`) .join("\n"); const diffExcerpt = diffFilesForContext - .map((file, index) => formatDiffExcerpt(file, stagedDiffs[index])) + .map((file, index) => formatDiffExcerpt(file, selectedDiffs[index])) .join("\n\n"); return truncateContext( @@ -152,15 +154,15 @@ async function buildCommitMessageContext({ "Recent commit subjects for style:", recentCommitLines || "- none", "", - `Staged files (${stagedFiles.length}):`, - stagedLines || "- none", - overflowCount > 0 ? `- ...and ${overflowCount} more staged files` : "", + `Selected files (${selectedFiles.length}):`, + selectedLines || "- none", + overflowCount > 0 ? `- ...and ${overflowCount} more selected files` : "", "", - `Staged diff summary for sampled files: +${totals.additions} -${totals.deletions}`, + `Selected diff summary for sampled files: +${totals.additions} -${totals.deletions}`, diffOverflowCount > 0 - ? `Diff excerpts include ${diffFilesForContext.length} of ${stagedFiles.length} staged files.` + ? `Diff excerpts include ${diffFilesForContext.length} of ${selectedFiles.length} selected files.` : "", - diffExcerpt ? `\nStaged patch excerpts:\n${diffExcerpt}` : "", + diffExcerpt ? `\nSelected patch excerpts:\n${diffExcerpt}` : "", existingDraftHint ? `\nCurrent draft:\n${existingDraftHint}` : "", ] .filter(Boolean) @@ -184,8 +186,9 @@ function normalizeGeneratedCommitMessage(message: string, mode: CommitMessageMod } const GitCommitPanel = ({ - stagedFilesCount, - stagedFiles, + selectedFiles, + commitMessage, + onCommitMessageChange, currentBranch, repoPath, ahead = 0, @@ -193,6 +196,7 @@ const GitCommitPanel = ({ onCommitSuccess, onPull, isPulling = false, + focusRequest = 0, }: GitCommitPanelProps) => { const { t } = useTranslation(); const aiAutocompleteProvider = useSettingsStore( @@ -203,7 +207,6 @@ const GitCommitPanel = ({ ? state.settings.aiAutocompleteCustomModelId : state.settings.aiAutocompleteModelId, ); - const [commitMessage, setCommitMessage] = useState(""); const [isCommitting, setIsCommitting] = useState(false); const [isGenerating, setIsGenerating] = useState(false); const [commitMessageMode, setCommitMessageMode] = useState("title"); @@ -212,6 +215,12 @@ const GitCommitPanel = ({ const [error, setError] = useState(null); const generateMenuAnchorRef = useRef(null); const commitTextareaRef = useRef(null); + const selectedFilesCount = selectedFiles.length; + + useEffect(() => { + if (focusRequest <= 0) return; + globalThis.requestAnimationFrame?.(() => commitTextareaRef.current?.focus()); + }, [focusRequest]); useLayoutEffect(() => { const textarea = commitTextareaRef.current; @@ -228,7 +237,7 @@ const GitCommitPanel = ({ }, [commitMessage]); const handleGenerateCommitMessage = async () => { - if (!repoPath || stagedFilesCount === 0) return; + if (!repoPath || selectedFilesCount === 0) return; setError(null); const existingDraftHint = commitMessage.trim(); @@ -238,7 +247,7 @@ const GitCommitPanel = ({ const selectedText = await buildCommitMessageContext({ repoPath, currentBranch, - stagedFiles, + selectedFiles, existingDraftHint, }); const { editedText } = await requestInlineEdit( @@ -251,8 +260,8 @@ const GitCommitPanel = ({ afterSelection: "", instruction: commitMessageMode === "title" - ? "Generate a concise Git commit subject from the staged changes. Return exactly one subject line and nothing else. Keep it under 72 characters when possible. Infer and match the repository's style from recent commit subjects. Do not force conventional commit format unless the recent commits clearly use it." - : "Generate a Git commit message from the staged changes. Return a subject line and a short body only when the body adds useful context. Keep the subject under 72 characters when possible. Infer and match the repository's style from recent commit subjects. Do not force conventional commit format unless the recent commits clearly use it.", + ? "Generate a concise Git commit subject from the selected changes. Return exactly one subject line and nothing else. Keep it under 72 characters when possible. Infer and match the repository's style from recent commit subjects. Do not force conventional commit format unless the recent commits clearly use it." + : "Generate a Git commit message from the selected changes. Return a subject line and a short body only when the body adds useful context. Keep the subject under 72 characters when possible. Infer and match the repository's style from recent commit subjects. Do not force conventional commit format unless the recent commits clearly use it.", filePath: getRepoLabel(repoPath), languageId: "git-commit", }, @@ -264,7 +273,7 @@ const GitCommitPanel = ({ return; } - setCommitMessage(message); + onCommitMessageChange(message); } catch (generationError) { if (generationError instanceof InlineEditError) { setError(generationError.message); @@ -277,7 +286,7 @@ const GitCommitPanel = ({ }; const handleCommit = async () => { - if (!repoPath || !commitMessage.trim() || stagedFilesCount === 0) return; + if (!repoPath || !commitMessage.trim() || selectedFilesCount === 0) return; // A conflicted merge/rebase must be resolved before the merge commit can // be finalized; guard here so Git's raw refusal never reaches the user. @@ -289,7 +298,10 @@ const GitCommitPanel = ({ let markerPaths: string[]; try { - markerPaths = await getConflictMarkerPaths(repoPath); + const selectedPathSet = new Set(selectedFiles.map((file) => file.path)); + markerPaths = (await getConflictMarkerPaths(repoPath)).filter((path) => + selectedPathSet.has(path), + ); } catch (markerError) { console.error("Failed to check staged files for conflict markers:", markerError); setError(t("git.verifyConflictMarkersFailed")); @@ -304,10 +316,14 @@ const GitCommitPanel = ({ setError(null); try { - const success = await commitChanges(repoPath, commitMessage.trim()); + const success = await commitSelectedChanges( + repoPath, + commitMessage.trim(), + selectedFiles.map((file) => file.path), + ); if (success) { useGitBlameStore.getState().actions.clearAllBlame(); - setCommitMessage(""); + onCommitMessageChange(""); onCommitSuccess?.(); } else { setError(t("git.commitChangesFailed")); @@ -362,8 +378,8 @@ const GitCommitPanel = ({ }; const isCommitDisabled = - !commitMessage.trim() || stagedFilesCount === 0 || isCommitting || isGenerating; - const isGenerateDisabled = stagedFilesCount === 0 || isGenerating || isCommitting; + !commitMessage.trim() || selectedFilesCount === 0 || isCommitting || isGenerating; + const isGenerateDisabled = selectedFilesCount === 0 || isGenerating || isCommitting; const hasRemoteChanges = ahead > 0 || behind > 0; const isRemoteActionLoading = remoteAction !== null; const composerButtonClassName = @@ -401,7 +417,7 @@ const GitCommitPanel = ({