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..f56a683fc --- /dev/null +++ b/macos/Tests/LitheTests/GitReferenceOperationsTests.swift @@ -0,0 +1,102 @@ +import Foundation +import Testing +@testable import Lithe +@testable import LitheGitModule + +@Suite("Git reference operations", .serialized) +struct GitReferenceOperationsTests { + @Test + func remoteReferenceWorkflowsUseCompleteIdentityThroughRustCore() async throws { + let core = RustCoreBridge() + guard core.isAvailable else { return } + let fixture = try await GitReferenceFixture() + let repository = fixture.repository + let mainName = try await fixture.git(["branch", "--show-current"]) + let mainReference = GitReference( + fullName: "refs/heads/\(mainName)", + shortName: mainName, + kind: .local, + isCurrent: true, + upstreamShortName: nil + ) + + try await fixture.git(["switch", "-q", "-c", "feature"]) + try Data("feature\n".utf8).write(to: repository.appendingPathComponent("tracked.txt")) + try await fixture.git(["commit", "-qam", "feature"]) + try await fixture.git(["update-ref", "refs/remotes/origin/feature", "refs/heads/feature"]) + try await fixture.git(["switch", "-q", mainName]) + try await 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: core) + + 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 await fixture.git(["branch", "--show-current"]) == "feature") + + try await 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() async throws { + repository = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-git-reference-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: repository, withIntermediateDirectories: true) + try await git(["init", "-q"]) + try await git(["config", "user.email", "tests@lithe.local"]) + try await git(["config", "user.name", "Lithe Tests"]) + try await git(["config", "core.autocrlf", "false"]) + try await git(["remote", "add", "origin", "."]) + try Data("main\n".utf8).write(to: repository.appendingPathComponent("tracked.txt")) + try await git(["add", "tracked.txt"]) + try await git(["commit", "-qm", "initial"]) + } + + deinit { + try? FileManager.default.removeItem(at: repository) + } + + @discardableResult + func git(_ arguments: [String]) async throws -> String { + let result = try await TestProcess.run( + executableURL: URL(fileURLWithPath: "/usr/bin/git"), + arguments: arguments, + currentDirectoryURL: repository + ) + guard result.terminationStatus == 0 else { + throw GitReferenceFixtureError.commandFailed( + arguments, + String(decoding: result.output, as: UTF8.self) + ) + } + return String(decoding: result.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..7bd9373ed 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -8,11 +8,12 @@ use crate::protocol::{ GitCommitLookupResponse, GitCommitResponse, GitComparisonResponse, GitConflictMarkerResponse, GitDiffHunkResponse, GitDiffResponse, GitDiffRowResponse, GitFileResponse, GitFilesResponse, GitHistoryResponse, GitIntegrationPreflightResponse, GitOperationStateResponse, - GitPullPreflightResponse, GitReferenceResponse, GitStashResponse, GitStashesResponse, - GitStatusResponse, GitWatchContextResponse, + GitPullPreflightResponse, GitPushPreviewResponse, GitReferenceResponse, GitStashResponse, + GitStashesResponse, GitStatusResponse, GitWatchContextResponse, }; use serde::{Deserialize, Serialize}; use std::cell::RefCell; +use std::collections::HashSet; use std::io::Read; use std::io::Write; #[cfg(target_os = "windows")] @@ -25,6 +26,7 @@ use std::time::Duration; const RECENT_BRANCH_LIMIT: usize = 5; const RECENT_BRANCH_REFLOG_LIMIT: &str = "100"; const DEFAULT_BRANCH_FALLBACKS: [&str; 2] = ["main", "master"]; +const DEFAULT_PUSH_PREVIEW_LIMIT: usize = 500; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -216,6 +218,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 +241,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)] @@ -251,10 +272,57 @@ pub struct GitWriteRequest { pub amend: bool, #[serde(default)] pub force: bool, + /// Tag scope for push: `none`, `all`, or `reachable`. + #[serde(default)] + pub push_tags: Option, #[serde(default)] pub auto_stash: bool, } +/// Exact on-disk index state restored when a selected-path commit does not complete. +struct GitIndexSnapshot { + path: PathBuf, + contents: Option>, +} + +impl GitIndexSnapshot { + fn capture(root: &str) -> Result { + let path = git_path(root, "index")?; + let contents = match std::fs::read(&path) { + Ok(contents) => Some(contents), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(git_index_io_error("read", error)), + }; + Ok(Self { path, contents }) + } + + fn restore(&self) -> Result<(), CoreError> { + match self.contents.as_ref() { + Some(contents) => std::fs::write(&self.path, contents) + .map_err(|error| git_index_io_error("restore", error)), + None => match std::fs::remove_file(&self.path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(git_index_io_error("restore", error)), + }, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request to resolve the exact destination and commits for a branch push. +pub struct GitPushPreviewRequest { + pub root: String, + #[serde(default)] + pub reference: Option, + /// Preferred complete local branch identity. + #[serde(default)] + pub git_reference: Option, + #[serde(default = "default_push_preview_limit")] + pub limit: usize, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] /// Request for a structured diff suitable for side-by-side rendering. @@ -264,6 +332,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 +391,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 +412,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 +430,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, } @@ -382,6 +468,10 @@ fn default_history_limit() -> usize { 300 } +fn default_push_preview_limit() -> usize { + DEFAULT_PUSH_PREVIEW_LIMIT +} + /// Executes an argument-based Git command after validating the workspace root. pub fn command(request: GitCommandRequest) -> Result { with_git_invocation_trace(|| { @@ -469,12 +559,22 @@ 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)?; + return commit_selected_paths(&root, paths, message, request.amend); + } 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 +602,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 +629,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 +650,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 +660,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 +686,62 @@ fn write_with_trace(request: GitWriteRequest) -> Result return push(&root, request.reference.as_deref()), + "deleteRemoteBranch" => { + let reference = request + .git_reference + .as_ref() + .ok_or_else(|| invalid_git_reference())?; + let reference = validated_git_reference(&root, reference)?; + if reference.kind != "remote" { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Remote branch deletion requires a remote Git reference", + )); + } + let (remote, branch) = + mutations::remote_branch_components(&root, &reference.full_name)?; + arguments = vec![ + "push".into(), + "--delete".into(), + "--".into(), + remote, + format!("refs/heads/{branch}"), + ]; + } + "push" => { + let reference = optional_write_request_reference(&root, &request)?; + return push( + &root, + reference.as_deref(), + request.force, + request.push_tags.as_deref(), + ); + } "checkout" => return checkout(&root, request), "checkoutRevision" => { arguments = vec![ @@ -678,10 +832,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 +950,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 +975,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(), @@ -952,18 +1126,36 @@ pub fn history(request: GitHistoryRequest) -> Result Result>(); + if !has_embedded_tracking_counts { + for parsed in &mut parsed_references { + if parsed.response.kind != "local" { + continue; + } + let Some(upstream_full_name) = parsed.upstream_full_name.as_deref() else { + continue; + }; + (parsed.response.ahead, parsed.response.behind) = + reference_tracking_counts(&root, &parsed.response.full_name, upstream_full_name); + } + } + let nonlocal_reference_output = readonly_command(GitCommandRequest { + root: root.clone(), + arguments: vec![ + "for-each-ref".to_string(), + "--sort=refname".to_string(), + "--format=%(refname)\t%(refname:short)\t%(HEAD)\t%(upstream:short)\t%(upstream)" + .to_string(), + "refs/remotes".to_string(), + "refs/tags".to_string(), + ], + input: None, + })?; + if nonlocal_reference_output.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git references failed") + .with_details(nonlocal_reference_output.output), + ); + } + parsed_references.extend( + nonlocal_reference_output + .output + .lines() + .filter_map(parse_reference), + ); + let references = parsed_references + .into_iter() + .map(|parsed| parsed.response) + .collect::>(); let recent_references = recent_local_references(&root, &references, RECENT_BRANCH_LIMIT); - let mut arguments = vec!["log".to_string()]; - if let Some(reference) = request.reference { + let selectors = if let Some(reference) = request.reference { if reference.starts_with('-') || reference.contains('\0') { return Err(CoreError::new( ErrorCode::InvalidRequest, "Invalid Git reference", )); } - arguments.push(reference); + vec![reference] } else { - arguments.push("--all".to_string()); - } + vec!["--all".to_string()] + }; + let (commits, has_more) = read_commit_log(&root, selectors, limit, "Git history failed")?; + Ok(GitHistoryResponse { + references, + recent_references, + commits, + has_more, + user_name, + user_email, + }) +} + +fn read_commit_log( + root: &str, + selectors: Vec, + limit: usize, + failure_message: &str, +) -> Result<(Vec, bool), CoreError> { + let mut arguments = vec!["log".to_string()]; + arguments.extend(selectors); arguments.extend([ "--topo-order".to_string(), "--decorate=short".to_string(), @@ -999,15 +1249,13 @@ pub fn history(request: GitHistoryRequest) -> Result Result>(); let has_more = all_commits.len() > limit; - Ok(GitHistoryResponse { - references, - recent_references, - commits: all_commits.into_iter().take(limit).collect(), - has_more, - user_name, - user_email, - }) + Ok((all_commits.into_iter().take(limit).collect(), has_more)) } /// Builds a bounded MRU list from Git's own checkout history. @@ -1209,7 +1450,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 +1466,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(), @@ -1368,18 +1620,26 @@ pub fn conflict_marker_paths( request: GitConflictMarkerRequest, ) -> Result { let root = validate_root(&request.root)?; + let paths = staged_conflict_marker_paths(&root, &[])?; + Ok(GitConflictMarkerResponse { paths }) +} - let found = readonly_command(GitCommandRequest { - root, - arguments: vec![ - "grep".to_string(), - "--cached".to_string(), - "-l".to_string(), - "-E".to_string(), - r"^(<<<<<<<|>>>>>>>|\|\|\|\|\|\|\|) ".to_string(), - ], - input: None, - })?; +fn staged_conflict_marker_paths( + root: &str, + pathspecs: &[String], +) -> Result, CoreError> { + let mut arguments = vec![ + "grep".to_string(), + "--cached".to_string(), + "-l".to_string(), + "-E".to_string(), + r"^(<<<<<<<|>>>>>>>|\|\|\|\|\|\|\|) ".to_string(), + ]; + if !pathspecs.is_empty() { + arguments.push("--".into()); + arguments.extend(pathspecs.iter().cloned()); + } + let found = execute_git_readonly(root, &arguments, None)?; // `git grep` exits 1 when nothing matches, which is not a failure here. if found.exit_code > 1 { return Err( @@ -1396,7 +1656,100 @@ pub fn conflict_marker_paths( .collect(); paths.sort(); paths.dedup(); - Ok(GitConflictMarkerResponse { paths }) + Ok(paths) +} + +fn commit_selected_paths( + root: &str, + paths: Vec, + message: String, + amend: bool, +) -> Result { + // `commit --only` needs untracked paths in the index, but that preparation + // must remain invisible if a hook, signer, or validation rejects the commit. + let index_snapshot = GitIndexSnapshot::capture(root)?; + let stage_arguments = ["add", "-A", "--"] + .into_iter() + .map(String::from) + .chain(paths.iter().cloned()) + .collect::>(); + let staged = match execute_git(root, &stage_arguments, None) { + Ok(staged) => staged, + Err(error) => return restore_index_after_error(&index_snapshot, error), + }; + if staged.exit_code != 0 { + restore_index_after_command_failure(&index_snapshot, &staged)?; + return Ok(staged); + } + + let marker_paths = match staged_conflict_marker_paths(root, &paths) { + Ok(paths) => paths, + Err(error) => return restore_index_after_error(&index_snapshot, error), + }; + if !marker_paths.is_empty() { + let error = CoreError::new( + ErrorCode::InvalidRequest, + "Conflict markers remain in selected files", + ) + .with_details(marker_paths.join(", ")); + return restore_index_after_error(&index_snapshot, error); + } + + let mut arguments = vec!["commit".into()]; + if amend { + arguments.push("--amend".into()); + } + arguments.extend(["--only".into(), "-m".into(), message, "--".into()]); + arguments.extend(paths); + let committed = match execute_git(root, &arguments, None) { + Ok(committed) => committed, + Err(error) => return restore_index_after_error(&index_snapshot, error), + }; + if committed.exit_code != 0 { + restore_index_after_command_failure(&index_snapshot, &committed)?; + } + Ok(committed) +} + +fn restore_index_after_error( + snapshot: &GitIndexSnapshot, + original: CoreError, +) -> Result { + match snapshot.restore() { + Ok(()) => Err(original), + Err(restore_error) => Err(CoreError::new( + ErrorCode::ProcessFailed, + "Git commit failed and the original index could not be restored", + ) + .with_details(format!("{}; {}", original.message, restore_error.message))), + } +} + +fn restore_index_after_command_failure( + snapshot: &GitIndexSnapshot, + command: &GitCommandResponse, +) -> Result<(), CoreError> { + snapshot.restore().map_err(|restore_error| { + CoreError::new( + ErrorCode::ProcessFailed, + "Git commit failed and the original index could not be restored", + ) + .with_details(format!( + "{}; {}", + command.output.trim(), + restore_error.message + )) + }) +} + +fn git_index_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} the Git index")) + .with_details(error.to_string()) } /// How an operation decides whether a dirty working tree is in its way. @@ -1419,7 +1772,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,78 +2256,845 @@ fn required_text(value: Option<&str>, label: &str) -> Result } } -fn validate_paths(paths: &[String]) -> Result, CoreError> { - if paths.is_empty() || paths.iter().any(|path| !is_safe_pathspec(path)) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Git operation contains an invalid path", - )); - } - Ok(paths.to_vec()) +#[derive(Clone)] +struct ValidatedGitReference { + full_name: String, + short_name: String, + kind: String, } -fn validated_revision(value: Option<&str>) -> Result { - let value = required_text(value, "revision")?; - validate_revision(&value)?; - Ok(value) +fn invalid_git_reference() -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git reference") } -pub(super) fn validated_reference(value: Option<&str>) -> Result { - let value = required_text(value, "reference")?; - if value.starts_with('-') || value.chars().any(char::is_whitespace) { - return Err(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()); } - Ok(value) -} -fn validated_stash_reference(value: Option<&str>) -> Result { - let value = required_text(value, "stash reference")?; - if value.starts_with('-') || value.contains(char::is_whitespace) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git stash 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()); } - Ok(value) -} -fn validated_branch_name(root: &str, value: Option<&str>) -> Result { - let value = required_text(value, "branch name")?; - let validation = execute_git( + let checked = execute_git_readonly( root, - &["check-ref-format".into(), "--branch".into(), value.clone()], + &["check-ref-format".into(), reference.full_name.clone()], None, )?; - if validation.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::InvalidRequest, "Invalid Git branch name") - .with_details(validation.output), - ); + if checked.exit_code != 0 { + return Err(invalid_git_reference()); } - Ok(value) -} - -fn local_branch_name(reference: &str) -> Result { - let branch = reference - .strip_prefix("refs/heads/") - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - CoreError::new( - ErrorCode::InvalidRequest, - "Only local branches support this Git operation", - ) - })?; - if !is_safe_pathspec(branch) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git branch name", - )); + if reference.kind == "remote" { + let (_, branch) = mutations::remote_branch_components(root, &reference.full_name)?; + if branch == "HEAD" { + return Err(invalid_git_reference()); + } } - Ok(branch.to_string()) -} + + 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( + ErrorCode::InvalidRequest, + "Git operation contains an invalid path", + )); + } + 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)?; + Ok(value) +} + +pub(super) fn validated_reference(value: Option<&str>) -> Result { + let value = required_text(value, "reference")?; + if value.starts_with('-') || value.chars().any(char::is_whitespace) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git reference", + )); + } + Ok(value) +} + +fn validated_stash_reference(value: Option<&str>) -> Result { + let value = required_text(value, "stash reference")?; + if value.starts_with('-') || value.contains(char::is_whitespace) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git stash reference", + )); + } + Ok(value) +} + +fn validated_branch_name(root: &str, value: Option<&str>) -> Result { + let value = required_text(value, "branch name")?; + let validation = execute_git( + root, + &["check-ref-format".into(), "--branch".into(), value.clone()], + None, + )?; + if validation.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git branch name") + .with_details(validation.output), + ); + } + Ok(value) +} + +fn local_branch_name(reference: &str) -> Result { + let branch = reference + .strip_prefix("refs/heads/") + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Only local branches support this Git operation", + ) + })?; + if !is_safe_pathspec(branch) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git branch name", + )); + } + Ok(branch.to_string()) +} pub(super) fn current_branch(root: &str) -> Result { let response = execute_git(root, &["branch".into(), "--show-current".into()], None)?; @@ -2024,29 +3148,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 +3179,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![ @@ -2167,40 +3304,214 @@ fn find_stash_reference(root: &str, message: &str) -> Result, Cor })) } -fn push(root: &str, reference: Option<&str>) -> Result { - let current = current_branch(root)?; - let branch = match reference { +#[derive(Clone)] +struct PushTarget { + local_branch: String, + remote: String, + remote_branch: String, + upstream: Option, + comparison_reference: Option, +} + +/// Resolves the remote destination and commits that a subsequent push will use. +pub fn push_preview(request: GitPushPreviewRequest) -> Result { + let root = validate_root(&request.root)?; + let reference = if let Some(reference) = request.git_reference.as_ref() { + let reference = validated_git_reference(&root, reference)?; + if reference.kind != "local" { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Only local branches can be pushed", + )); + } + Some(reference.full_name) + } else { + request + .reference + .as_deref() + .map(|reference| validated_reference(Some(reference))) + .transpose()? + }; + let target = resolve_push_target(&root, reference.as_deref())?; + let limit = request.limit.clamp(1, 5_000); + let local_reference = format!("refs/heads/{}", target.local_branch); + let selectors = if let Some(comparison_reference) = target.comparison_reference.as_ref() { + vec![format!("{comparison_reference}..{local_reference}")] + } else { + // A branch without an upstream may still be based on another remote branch. + // Excluding every commit reachable from the selected remote keeps the preview + // focused on commits that publication would introduce. + vec![ + local_reference, + "--not".to_string(), + format!("--remotes={}", target.remote), + ] + }; + let (commits, has_more) = read_commit_log(&root, selectors, limit, "Git push preview failed")?; + + Ok(GitPushPreviewResponse { + local_branch: target.local_branch, + remote: target.remote, + remote_branch: target.remote_branch, + upstream: target.upstream, + commits, + has_more, + }) +} + +fn push( + root: &str, + reference: Option<&str>, + force: bool, + push_tags: Option<&str>, +) -> Result { + let tag_argument = match push_tags.unwrap_or("none") { + "none" => None, + "all" => Some("--tags"), + "reachable" => Some("--follow-tags"), + _ => { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Unsupported Git push tag scope", + )) + } + }; + let target = resolve_push_target(root, reference)?; + let mut arguments = vec!["push".to_string()]; + if force { + // A lease refuses to overwrite a remote update the local repository has not seen. + arguments.push("--force-with-lease".into()); + } + if let Some(tag_argument) = tag_argument { + arguments.push(tag_argument.into()); + } + if target.upstream.is_none() { + arguments.push("--set-upstream".into()); + } + arguments.extend([ + target.remote, + format!( + "refs/heads/{}:refs/heads/{}", + target.local_branch, target.remote_branch + ), + ]); + execute_git(root, &arguments, None) +} + +fn resolve_push_target(root: &str, reference: Option<&str>) -> Result { + let local_branch = match reference { Some(reference) => local_branch_name(&validated_reference(Some(reference))?)?, - None => current.clone(), + None => current_branch(root)?, }; - let upstream = execute_git( + let upstream_lookup = execute_git_readonly( root, &[ "rev-parse".into(), "--abbrev-ref".into(), - format!("{branch}@{{upstream}}"), + format!("{local_branch}@{{upstream}}"), ], None, )?; - if upstream.exit_code == 0 { - let tracking_name = upstream.output.trim(); - if branch == current { - return execute_git(root, &["push".into()], None); - } - if let Some((remote, remote_branch)) = tracking_name.split_once('/') { - return execute_git( - root, - &[ - "push".into(), - remote.to_string(), - format!("{branch}:{remote_branch}"), - ], - None, - ); - } + let (upstream, upstream_components) = if upstream_lookup.exit_code == 0 { + let upstream = upstream_lookup.output.trim().to_string(); + let components = + mutations::remote_branch_components(root, &format!("refs/remotes/{upstream}"))?; + (Some(upstream), Some(components)) + } else { + (None, None) + }; + + let branch_push_remote = + read_git_config_value(root, &format!("branch.{local_branch}.pushRemote"))?; + let default_push_remote = read_git_config_value(root, "remote.pushDefault")?; + let branch_remote = read_git_config_value(root, &format!("branch.{local_branch}.remote"))? + .filter(|remote| remote != "."); + let remote = branch_push_remote + .or(default_push_remote) + .or_else(|| { + upstream_components + .as_ref() + .map(|(remote, _)| remote.clone()) + }) + .or(branch_remote) + .map(Ok) + .unwrap_or_else(|| default_push_remote_name(root))?; + validate_push_component(&remote)?; + + let remote_branch = upstream_components + .as_ref() + .filter(|(upstream_remote, _)| upstream_remote == &remote) + .map(|(_, branch)| branch.clone()) + .unwrap_or_else(|| local_branch.clone()); + let target_reference = format!("refs/remotes/{remote}/{remote_branch}"); + let comparison_reference = if reference_exists(root, &target_reference)? { + Some(target_reference) + } else { + None + }; + + Ok(PushTarget { + local_branch, + remote, + remote_branch, + upstream, + comparison_reference, + }) +} + +fn read_git_config_value(root: &str, key: &str) -> Result, CoreError> { + let configured = execute_git_readonly( + root, + &["config".into(), "--get".into(), key.to_string()], + None, + )?; + if configured.exit_code == 1 { + return Ok(None); + } + if configured.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not read Git push configuration", + ) + .with_details(configured.output)); + } + let value = configured.output.trim(); + if value.is_empty() { + return Ok(None); } + validate_push_component(value)?; + Ok(Some(value.to_string())) +} + +fn reference_exists(root: &str, reference: &str) -> Result { + let result = execute_git_readonly( + root, + &[ + "show-ref".into(), + "--verify".into(), + "--quiet".into(), + reference.to_string(), + ], + None, + )?; + if result.exit_code > 1 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not inspect Git push destination", + ) + .with_details(result.output)); + } + Ok(result.exit_code == 0) +} - let remotes = execute_git(root, &["remote".into()], None)?; +fn default_push_remote_name(root: &str) -> Result { + let remotes = execute_git_readonly(root, &["remote".into()], None)?; + if remotes.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Could not list Git remotes") + .with_details(remotes.output), + ); + } let remote = remotes .output .lines() @@ -2212,23 +3523,24 @@ fn push(root: &str, reference: Option<&str>) -> Result execute_git( - root, - &[ - "push".into(), - "--set-upstream".into(), - remote.to_string(), - branch, - ], - None, - ), - None => Err(CoreError::new( + }) + .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "No Git remote is configured"))?; + validate_push_component(remote)?; + Ok(remote.to_string()) +} + +fn validate_push_component(value: &str) -> Result<(), CoreError> { + if value.is_empty() + || value.starts_with('-') + || value.contains(['\0', '\n', '\r']) + || value.chars().any(char::is_whitespace) + { + return Err(CoreError::new( ErrorCode::InvalidRequest, - "No Git remote is configured", - )), + "Invalid Git push destination", + )); } + Ok(()) } fn publish_branch(root: &str, name: Option<&str>) -> Result { @@ -2350,30 +3662,43 @@ 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) = mutations::remote_branch_components(root, &reference.full_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}"); @@ -2388,12 +3713,12 @@ pub(super) fn switch_reference( None, )?; if existing.exit_code == 0 { - base.push(local_name.to_string()); + base.push(local_name); } else { base.push("--track".into()); base.push("-c".into()); - base.push(local_name.to_string()); - base.push(reference); + base.push(local_name); + base.push(reference.full_name.clone()); } execute_git(root, &base, None) } @@ -2404,7 +3729,13 @@ pub(super) fn switch_reference( } } -fn parse_reference(line: &str) -> Option { +/// Parsed reference plus the full upstream identity used by compatibility counting. +struct ParsedGitReference { + response: GitReferenceResponse, + upstream_full_name: Option, +} + +fn parse_reference(line: &str) -> Option { let columns = line.split('\t').collect::>(); if columns.len() < 4 || columns[1].ends_with("/HEAD") { return None; @@ -2416,15 +3747,56 @@ fn parse_reference(line: &str) -> Option { } else { "tag" }; - Some(GitReferenceResponse { - full_name: columns[0].to_string(), - short_name: columns[1].to_string(), - kind: kind.to_string(), - is_current: columns[2].trim() == "*", - upstream_short_name: (!columns[3].is_empty()).then(|| columns[3].to_string()), + let upstream_short_name = (!columns[3].is_empty()).then(|| columns[3].to_string()); + let (ahead, behind) = if kind == "local" && upstream_short_name.is_some() { + parse_ahead_behind_counts(columns.get(5).copied().unwrap_or_default()) + } else { + (0, 0) + }; + Some(ParsedGitReference { + response: GitReferenceResponse { + full_name: columns[0].to_string(), + short_name: columns[1].to_string(), + kind: kind.to_string(), + is_current: columns[2].trim() == "*", + upstream_short_name, + ahead, + behind, + }, + upstream_full_name: columns + .get(4) + .filter(|value| !value.is_empty()) + .map(|value| (*value).to_string()), }) } +fn parse_ahead_behind_counts(value: &str) -> (usize, usize) { + let mut values = value + .split_whitespace() + .filter_map(|value| value.parse::().ok()); + (values.next().unwrap_or(0), values.next().unwrap_or(0)) +} + +fn reference_tracking_counts(root: &str, local: &str, upstream: &str) -> (usize, usize) { + let Ok(output) = readonly_command(GitCommandRequest { + root: root.to_string(), + arguments: vec![ + "rev-list".to_string(), + "--left-right".to_string(), + "--count".to_string(), + format!("{upstream}...{local}"), + ], + input: None, + }) else { + return (0, 0); + }; + if output.exit_code != 0 { + return (0, 0); + } + let (behind, ahead) = parse_ahead_behind_counts(&output.output); + (ahead, behind) +} + fn parse_commit(line: &str) -> Option { let columns = line.split('\u{1f}').collect::>(); if columns.len() < 8 { @@ -3159,7 +4531,8 @@ mod tests { GitCommandInvocation, GitCommandResponse, GitProcessOutput, MAX_ALIGNMENT_CELLS, }; use crate::protocol::{ - CoreError, ErrorCode, GitCommitResponse, GitHistoryResponse, GitReferenceResponse, + CoreError, ErrorCode, GitCommitResponse, GitHistoryResponse, GitPushPreviewResponse, + GitReferenceResponse, }; use serde_json::Value; @@ -3413,6 +4786,8 @@ mod tests { kind: "local".into(), is_current: true, upstream_short_name: None, + ahead: 0, + behind: 0, }; let main = GitReferenceResponse { full_name: "refs/heads/main".into(), @@ -3420,6 +4795,8 @@ mod tests { kind: "local".into(), is_current: false, upstream_short_name: Some("origin/main".into()), + ahead: 2, + behind: 1, }; let response = GitHistoryResponse { references: vec![feature.clone(), main.clone()], @@ -3445,6 +4822,37 @@ mod tests { ); } + #[test] + fn push_preview_response_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/git/push-preview-v1.json" + ))) + .expect("Git push preview fixture should be valid JSON"); + let response = GitPushPreviewResponse { + local_branch: "feature/core".into(), + remote: "origin".into(), + remote_branch: "feature/core".into(), + upstream: Some("origin/feature/core".into()), + commits: vec![GitCommitResponse { + hash: "2222222222222222222222222222222222222222".into(), + short_hash: "2222222".into(), + parent_hashes: vec!["1111111111111111111111111111111111111111".into()], + author_name: "Lithe Developer".into(), + author_email: "developer@lithe.local".into(), + date: "2026/08/31 10:30".into(), + subject: "Add push preview".into(), + decorations: "HEAD -> feature/core".into(), + }], + has_more: false, + }; + + assert_eq!( + serde_json::to_value(response).expect("Git push preview should serialize"), + fixture + ); + } + #[test] fn command_error_response_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( 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/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index fc5b6d447..624fa88de 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -207,6 +207,8 @@ pub enum CoreCommand { GitApply, /// Lists references and bounded commit history (`git.history`). GitHistory, + /// Resolves the destination and commits for a branch push (`git.pushPreview`). + GitPushPreview, /// Resolves metadata for one commit (`git.commit`). GitCommit, /// Lists paths changed by one commit (`git.commitFiles`). @@ -329,6 +331,7 @@ impl CoreCommand { "git.diff" => Some(Self::GitDiff), "git.apply" => Some(Self::GitApply), "git.history" => Some(Self::GitHistory), + "git.pushPreview" => Some(Self::GitPushPreview), "git.commit" => Some(Self::GitCommit), "git.commitFiles" => Some(Self::GitCommitFiles), "git.comparison" => Some(Self::GitComparison), @@ -414,4 +417,12 @@ mod tests { assert!(CoreCommand::parse(command).is_some(), "missing {command}"); } } + + #[test] + fn parses_git_push_preview_command() { + assert!(matches!( + CoreCommand::parse("git.pushPreview"), + Some(CoreCommand::GitPushPreview) + )); + } } diff --git a/rust/lithe-core/src/protocol/contracts.rs b/rust/lithe-core/src/protocol/contracts.rs index 2bf803bed..b4a1d6f35 100644 --- a/rust/lithe-core/src/protocol/contracts.rs +++ b/rust/lithe-core/src/protocol/contracts.rs @@ -393,6 +393,10 @@ pub struct GitReferenceResponse { pub kind: String, pub is_current: bool, pub upstream_short_name: Option, + /// Commits present only on this local branch compared with its upstream. + pub ahead: usize, + /// Commits present only on this local branch's upstream. + pub behind: usize, } #[derive(Debug, Clone, Serialize)] @@ -422,6 +426,24 @@ pub struct GitHistoryResponse { pub user_email: Option, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Resolved destination and bounded commits for one branch push. +pub struct GitPushPreviewResponse { + /// Local branch that will be sent to the remote. + pub local_branch: String, + /// Remote selected from the branch upstream or repository defaults. + pub remote: String, + /// Branch name created or updated on the remote. + pub remote_branch: String, + /// Configured upstream short name, or `None` before first publication. + pub upstream: Option, + /// Commits reachable from the local branch but not its resolved remote base. + pub commits: Vec, + /// Whether more commits exist beyond the bounded preview. + pub has_more: bool, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Exact lookup result for one commit. diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index b87bd388b..537d247c8 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -9,8 +9,8 @@ use crate::git::{ self, GitApplyRequest, GitBlameRequest, GitCheckoutPreflightRequest, GitCommandRequest, GitCommitFilesRequest, GitCommitRequest, GitComparisonRequest, GitConflictMarkerRequest, GitDiffRequest, GitHistoryRequest, GitIntegrationPreflightRequest, GitOperationStateRequest, - GitPullPreflightRequest, GitPullRequestContextRequest, GitStashesRequest, GitStatusRequest, - GitWatchContextRequest, GitWriteRequest, + GitPullPreflightRequest, GitPullRequestContextRequest, GitPushPreviewRequest, + GitStashesRequest, GitStatusRequest, GitWatchContextRequest, GitWriteRequest, }; use crate::github::{NormalizeResponseRequest, ParseRemoteRequest, RequestPlanRequest}; use crate::languages::{ @@ -1519,6 +1519,24 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::GitPushPreview => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git push preview request", + ) + .with_details(error.to_string()) + }) + .and_then(git::push_preview) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git push preview response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::GitCommit => match serde_json::from_value::(parsed.payload) .map_err(|error| { CoreError::new(ErrorCode::InvalidRequest, "Invalid Git commit request") diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index e20d535fe..6698c561f 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -180,6 +180,458 @@ 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 staged change\n") + .expect("file should be writable"); + assert!(run(&["add", "selected.txt"]).status.success()); + fs::write( + root.join("selected.txt"), + "selected staged and unstaged 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 staged and unstaged 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_restores_the_index_when_a_selected_commit_hook_fails() { + let root = temporary_root("git-write-selected-hook-failure"); + 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 worktree change\n") + .expect("file should be writable"); + fs::write(root.join("other.txt"), "other staged change\n").expect("file should be writable"); + assert!(run(&["add", "other.txt"]).status.success()); + let cached_before = run(&["diff", "--cached", "--binary"]).stdout; + + let hook = root.join(".git/hooks/pre-commit"); + fs::write(&hook, "#!/bin/sh\nexit 1\n").expect("hook should be writable"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(&hook) + .expect("hook metadata should be readable") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook, permissions).expect("hook should be executable"); + } + + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "selected-commit-hook-failure", + "command": "git.write", + "payload": { + "root": root, + "operation": "commit", + "message": "must fail", + "paths": ["selected.txt"] + } + })) + .expect("selected commit request should encode"), + )) + .expect("selected commit response should be JSON"); + assert_eq!(response["ok"], true, "{response:?}"); + assert_ne!(response["data"]["exitCode"], 0, "{response:?}"); + assert_eq!(run(&["diff", "--cached", "--binary"]).stdout, cached_before); + assert_eq!( + String::from_utf8_lossy(&run(&["diff", "--name-only"]).stdout).trim(), + "selected.txt" + ); + + fs::remove_dir_all(root).expect("temporary workspace should be removable"); +} + +#[test] +fn git_write_checks_selected_worktree_conflict_markers_before_committing() { + let root = temporary_root("git-write-selected-markers"); + 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"); + assert!(run(&["add", "--all"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + let head_before = run(&["rev-parse", "HEAD"]).stdout; + + fs::write( + root.join("selected.txt"), + "<<<<<<< ours\nleft\n=======\nright\n>>>>>>> theirs\n", + ) + .expect("file should be writable"); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "selected-commit-markers", + "command": "git.write", + "payload": { + "root": root, + "operation": "commit", + "message": "must not commit markers", + "paths": ["selected.txt"] + } + })) + .expect("selected commit request should encode"), + )) + .expect("selected commit response should be JSON"); + + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!( + response["data"]["operationError"]["message"], + "Conflict markers remain in selected files" + ); + assert!(run(&["diff", "--cached", "--name-only"]).stdout.is_empty()); + assert_eq!(run(&["rev-parse", "HEAD"]).stdout, head_before); + + 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 +715,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 +724,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 +740,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 +751,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 +1089,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 +1769,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"); @@ -1360,6 +1951,88 @@ fn git_history_returns_references_and_commit_graph_fields() { fs::remove_dir_all(root).expect("temporary workspace should be removable"); } +#[test] +fn git_history_reports_tracking_counts_for_a_noncurrent_local_branch() { + struct RemoveOnDrop(std::path::PathBuf); + + impl Drop for RemoveOnDrop { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + let root = temporary_root("git-history-tracking-counts"); + let _cleanup = RemoveOnDrop(root.clone()); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q", "-b", "main"]).status.success()); + assert!(run(&[ + "remote", + "add", + "origin", + "https://example.invalid/repository.git", + ]) + .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("story.txt"), "base\n").expect("test file should be writable"); + assert!(run(&["add", "story.txt"]).status.success()); + assert!(run(&["commit", "-qm", "base"]).status.success()); + assert!(run(&["branch", "feature"]).status.success()); + + assert!(run(&["checkout", "-q", "feature"]).status.success()); + fs::write(root.join("story.txt"), "local\n").expect("test file should be writable"); + assert!(run(&["commit", "-qam", "local feature"]).status.success()); + + assert!(run(&["checkout", "-q", "main"]).status.success()); + assert!(run(&["checkout", "-qb", "remote-feature"]).status.success()); + fs::write(root.join("story.txt"), "remote\n").expect("test file should be writable"); + assert!(run(&["commit", "-qam", "remote feature"]).status.success()); + let remote_commit = git_text(&root, &["rev-parse", "HEAD"]); + assert!(run(&["checkout", "-q", "main"]).status.success()); + assert!( + run(&["update-ref", "refs/remotes/origin/feature", &remote_commit,]) + .status + .success() + ); + assert!(run(&["branch", "-D", "remote-feature"]).status.success()); + assert!( + run(&["branch", "--set-upstream-to=origin/feature", "feature",]) + .status + .success() + ); + + let request = serde_json::json!({ + "id": "history-tracking-counts", + "command": "git.history", + "payload": {"root": root, "limit": 10} + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("history request should encode"), + )) + .expect("history response should be JSON"); + assert_eq!(response["ok"], true, "{response:?}"); + let feature = response["data"]["references"] + .as_array() + .expect("references should be an array") + .iter() + .find(|reference| reference["shortName"] == "feature") + .expect("feature reference should be returned"); + assert_eq!(feature["isCurrent"], false); + assert_eq!(feature["upstreamShortName"], "origin/feature"); + assert_eq!(feature["ahead"], 1); + assert_eq!(feature["behind"], 1); +} + #[test] fn git_history_returns_bounded_recent_checkout_order_and_stable_fallback() { struct RemoveOnDrop(std::path::PathBuf); @@ -1924,3 +2597,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/rust/lithe-core/tests/git_push.rs b/rust/lithe-core/tests/git_push.rs new file mode 100644 index 000000000..bcb804def --- /dev/null +++ b/rust/lithe-core/tests/git_push.rs @@ -0,0 +1,253 @@ +use lithe_core::execute_json; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +static NEXT_FIXTURE_ID: AtomicU64 = AtomicU64::new(0); + +struct GitFixture { + root: PathBuf, +} + +impl GitFixture { + fn new() -> Self { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be valid") + .as_nanos(); + let fixture_id = NEXT_FIXTURE_ID.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "lithe-git-push-{}-{nonce}-{fixture_id}", + std::process::id() + )); + fs::create_dir_all(&root).expect("Git fixture root should be creatable"); + Self { root } + } +} + +impl Drop for GitFixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +fn git(directory: &Path, arguments: &[&str]) -> Output { + Command::new("git") + .args(arguments) + .current_dir(directory) + .output() + .expect("git should be available") +} + +fn require_git(directory: &Path, arguments: &[&str]) { + let output = git(directory, arguments); + assert!( + output.status.success(), + "git {} failed: {}", + arguments.join(" "), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn core(command: &str, root: &Path, payload: Value) -> Value { + let mut payload = payload.as_object().cloned().unwrap_or_default(); + payload.insert("root".into(), json!(root)); + let request = json!({ + "id": format!("test-{command}"), + "command": command, + "payload": payload + }); + serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("Core request should encode"), + )) + .expect("Core response should be JSON") +} + +fn initialize_repository(fixture: &GitFixture) -> (PathBuf, PathBuf) { + let remote = fixture.root.join("remote.git"); + let repository = fixture.root.join("repository"); + fs::create_dir_all(&remote).expect("remote should be creatable"); + fs::create_dir_all(&repository).expect("repository should be creatable"); + require_git(&remote, &["init", "--bare", "-q"]); + require_git(&repository, &["init", "-q"]); + require_git(&repository, &["config", "user.email", "tests@lithe.local"]); + require_git(&repository, &["config", "user.name", "Lithe Tests"]); + fs::write(repository.join("tracked.txt"), "initial\n") + .expect("tracked fixture should be writable"); + require_git(&repository, &["add", "tracked.txt"]); + require_git(&repository, &["commit", "-q", "-m", "initial"]); + require_git(&repository, &["branch", "-M", "main"]); + let remote_path = remote.to_string_lossy().into_owned(); + require_git(&repository, &["remote", "add", "origin", &remote_path]); + require_git(&repository, &["push", "-q", "-u", "origin", "main"]); + (repository, remote) +} + +fn add_bare_remote(fixture: &GitFixture, repository: &Path, name: &str) -> PathBuf { + let remote = fixture.root.join(format!("{}.git", name.replace('/', "-"))); + fs::create_dir_all(&remote).expect("additional remote should be creatable"); + require_git(&remote, &["init", "--bare", "-q"]); + let remote_path = remote.to_string_lossy().into_owned(); + require_git(repository, &["remote", "add", name, &remote_path]); + remote +} + +#[test] +fn push_preview_and_write_share_destination_and_safe_options() { + let fixture = GitFixture::new(); + let (repository, remote) = initialize_repository(&fixture); + fs::write(repository.join("tracked.txt"), "initial\nsecond\n") + .expect("tracked fixture should be writable"); + require_git(&repository, &["commit", "-q", "-am", "second"]); + require_git(&repository, &["tag", "-a", "v1", "-m", "version one"]); + + let preview = core("git.pushPreview", &repository, json!({})); + assert_eq!(preview["ok"], true, "response: {preview}"); + assert_eq!(preview["data"]["localBranch"], "main"); + assert_eq!(preview["data"]["remote"], "origin"); + assert_eq!(preview["data"]["remoteBranch"], "main"); + assert_eq!(preview["data"]["upstream"], "origin/main"); + assert_eq!(preview["data"]["commits"].as_array().map(Vec::len), Some(1)); + assert_eq!(preview["data"]["commits"][0]["subject"], "second"); + + let pushed = core( + "git.write", + &repository, + json!({ + "operation": "push", + "force": true, + "pushTags": "reachable" + }), + ); + assert_eq!(pushed["ok"], true, "response: {pushed}"); + let arguments = pushed["data"]["arguments"] + .as_array() + .expect("push arguments should be present") + .iter() + .filter_map(Value::as_str) + .collect::>(); + assert!(arguments.contains(&"--force-with-lease")); + assert!(!arguments.contains(&"--force")); + assert!(arguments.contains(&"--follow-tags")); + assert!(arguments.contains(&"refs/heads/main:refs/heads/main")); + + let local_head = git(&repository, &["rev-parse", "HEAD"]); + let remote_head = git(&remote, &["rev-parse", "refs/heads/main"]); + assert_eq!(local_head.stdout, remote_head.stdout); + require_git(&remote, &["show-ref", "--verify", "refs/tags/v1"]); +} + +#[test] +fn push_preview_uses_default_remote_when_branch_has_no_upstream() { + let fixture = GitFixture::new(); + let (repository, _) = initialize_repository(&fixture); + require_git(&repository, &["branch", "--unset-upstream"]); + fs::write(repository.join("local.txt"), "local\n").expect("local fixture should be writable"); + require_git(&repository, &["add", "local.txt"]); + require_git(&repository, &["commit", "-q", "-m", "local only"]); + + let preview = core("git.pushPreview", &repository, json!({})); + assert_eq!(preview["ok"], true, "response: {preview}"); + assert_eq!(preview["data"]["remote"], "origin"); + assert_eq!(preview["data"]["remoteBranch"], "main"); + assert!(preview["data"]["upstream"].is_null()); + assert_eq!(preview["data"]["commits"].as_array().map(Vec::len), Some(1)); + assert_eq!(preview["data"]["commits"][0]["subject"], "local only"); +} + +#[test] +fn push_preview_preserves_a_configured_remote_name_with_slashes() { + let fixture = GitFixture::new(); + let (repository, _) = initialize_repository(&fixture); + require_git(&repository, &["remote", "rename", "origin", "team/origin"]); + fs::write(repository.join("nested-remote.txt"), "local\n") + .expect("nested remote fixture should be writable"); + require_git(&repository, &["add", "nested-remote.txt"]); + require_git(&repository, &["commit", "-q", "-m", "nested remote"]); + + let preview = core("git.pushPreview", &repository, json!({})); + assert_eq!(preview["ok"], true, "response: {preview}"); + assert_eq!(preview["data"]["remote"], "team/origin"); + assert_eq!(preview["data"]["remoteBranch"], "main"); + assert_eq!(preview["data"]["upstream"], "team/origin/main"); + assert_eq!(preview["data"]["commits"][0]["subject"], "nested remote"); +} + +#[test] +fn branch_push_remote_overrides_the_tracking_remote_for_preview_and_push() { + let fixture = GitFixture::new(); + let (repository, _) = initialize_repository(&fixture); + let fork = add_bare_remote(&fixture, &repository, "fork"); + require_git(&repository, &["config", "branch.main.pushRemote", "fork"]); + fs::write(repository.join("fork.txt"), "fork\n").expect("fixture should be writable"); + require_git(&repository, &["add", "fork.txt"]); + require_git(&repository, &["commit", "-q", "-m", "fork change"]); + + let preview = core("git.pushPreview", &repository, json!({})); + assert_eq!(preview["ok"], true, "response: {preview}"); + assert_eq!(preview["data"]["remote"], "fork"); + assert_eq!(preview["data"]["remoteBranch"], "main"); + assert_eq!(preview["data"]["upstream"], "origin/main"); + + let pushed = core("git.write", &repository, json!({ "operation": "push" })); + assert_eq!(pushed["ok"], true, "response: {pushed}"); + assert_eq!(pushed["data"]["exitCode"], 0, "response: {pushed}"); + assert_eq!( + git(&repository, &["rev-parse", "HEAD"]).stdout, + git(&fork, &["rev-parse", "refs/heads/main"]).stdout + ); +} + +#[test] +fn remote_push_default_overrides_the_tracking_remote() { + let fixture = GitFixture::new(); + let (repository, _) = initialize_repository(&fixture); + add_bare_remote(&fixture, &repository, "fork"); + require_git(&repository, &["config", "remote.pushDefault", "fork"]); + + let preview = core("git.pushPreview", &repository, json!({})); + assert_eq!(preview["ok"], true, "response: {preview}"); + assert_eq!(preview["data"]["remote"], "fork"); + assert_eq!(preview["data"]["remoteBranch"], "main"); + assert_eq!(preview["data"]["upstream"], "origin/main"); +} + +#[test] +fn typed_remote_deletion_preserves_remote_names_with_slashes() { + let fixture = GitFixture::new(); + let (repository, remote) = initialize_repository(&fixture); + require_git(&repository, &["remote", "rename", "origin", "team/origin"]); + require_git( + &repository, + &[ + "push", + "-q", + "team/origin", + "refs/heads/main:refs/heads/feature/orders", + ], + ); + + let deleted = core( + "git.write", + &repository, + json!({ + "operation": "deleteRemoteBranch", + "gitReference": { + "fullName": "refs/remotes/team/origin/feature/orders", + "shortName": "team/origin/feature/orders", + "kind": "remote" + } + }), + ); + assert_eq!(deleted["ok"], true, "response: {deleted}"); + assert_eq!(deleted["data"]["exitCode"], 0, "response: {deleted}"); + assert!(!git( + &remote, + &["show-ref", "--verify", "refs/heads/feature/orders"] + ) + .status + .success()); +} diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 7cb37cfdf..9dde8e4a0 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -145,6 +145,7 @@ stable error code and a user-facing message: | `git.diff` | Produce a structured working-tree, index, reference, or commit patch | | `git.apply` | Apply or check a patch in `stage`, `unstage`, `discard`, or Shelf restore mode | | `git.history` | Return deterministic refs, recent local branches, commits, parent hashes, decorations, and pagination state | +| `git.pushPreview` | Resolve a local branch push destination and the bounded commits not present on that remote base | | `git.commit` | Return one structured commit by revision | | `git.commitFiles` | Return files changed by one commit | | `git.comparison` | Return files changed between a reference and the working tree | @@ -241,14 +242,14 @@ 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`, -`stashApply`, `stashPop`, `stashDrop`, `operationContinue`, `operationAbort`, and -`operationSkip`. Optional fields are `paths`, `reference`, `referenceKind`, -`revision`, `name`, `message`, `remote`, `destination`, `mode`, -`includeUntracked`, `checkout`, and `amend`. +`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`, `deleteRemoteBranch`, `operationContinue`, +`operationAbort`, and `operationSkip`. Optional fields are `paths`, `reference`, `referenceKind`, +`gitReference`, `revision`, `revisions`, `name`, `message`, `remote`, `destination`, `mode`, +`includeUntracked`, `checkout`, `amend`, `force`, and `pushTags`. The core validates pathspecs, revisions, branch names, references, reset modes, stash references, and operation-specific required fields before invoking Git. @@ -276,16 +277,60 @@ 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. +`git.pushPreview` accepts `root`, an optional complete local `gitReference` or +legacy `reference`, and an optional bounded `limit`. It returns `localBranch`, +`remote`, `remoteBranch`, nullable `upstream`, `commits`, and `hasMore` using +`shared/fixtures/git/push-preview-v1.json`. The push destination follows +`branch..pushRemote`, then `remote.pushDefault`, the configured upstream +remote, `branch..remote`, and finally `origin` or the first configured +remote. A destination without a fetched tracking reference previews commits not +reachable from that remote. The `push` mutation resolves the same destination. `force` +uses `--force-with-lease`; `pushTags` accepts `none`, `all`, or `reachable` and +maps to no tag option, `--tags`, or `--follow-tags` respectively. + +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. + +`deleteRemoteBranch` requires a complete remote `gitReference`. Core resolves +the configured remote with longest-prefix matching and invokes a structured +remote branch deletion; platforms must not split `shortName` 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. Core checks conflict markers after preparing that final +snapshot and restores the exact original index if staging, validation, a hook, +signing, or commit execution fails. 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 +339,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 +352,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 +372,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 +382,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 @@ -350,6 +406,9 @@ and the optional effective `userName` and `userEmail` from repository Git configuration. Commit parents are explicit so clients can render merge topology without re-parsing Git output. The identity fields let clients implement a stable `me` filter without guessing from recent commits. +Each local reference with an upstream also returns numeric `ahead` and `behind` +counts against that fetched remote-tracking reference. References without an +upstream, remote references, and tags return zero for both fields. `git.commit` accepts `root` and a revision, returning one `commit` object. `git.blame` accepts `root` and a workspace-relative `path`; its line numbers 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/history-response-v1.json b/shared/fixtures/git/history-response-v1.json index 44d376d19..595d3d0c1 100644 --- a/shared/fixtures/git/history-response-v1.json +++ b/shared/fixtures/git/history-response-v1.json @@ -5,14 +5,18 @@ "shortName": "feature/recent", "kind": "local", "isCurrent": true, - "upstreamShortName": null + "upstreamShortName": null, + "ahead": 0, + "behind": 0 }, { "fullName": "refs/heads/main", "shortName": "main", "kind": "local", "isCurrent": false, - "upstreamShortName": "origin/main" + "upstreamShortName": "origin/main", + "ahead": 2, + "behind": 1 } ], "recentReferences": [ @@ -21,14 +25,18 @@ "shortName": "feature/recent", "kind": "local", "isCurrent": true, - "upstreamShortName": null + "upstreamShortName": null, + "ahead": 0, + "behind": 0 }, { "fullName": "refs/heads/main", "shortName": "main", "kind": "local", "isCurrent": false, - "upstreamShortName": "origin/main" + "upstreamShortName": "origin/main", + "ahead": 2, + "behind": 1 } ], "commits": [ diff --git a/shared/fixtures/git/push-preview-v1.json b/shared/fixtures/git/push-preview-v1.json new file mode 100644 index 000000000..8faed3468 --- /dev/null +++ b/shared/fixtures/git/push-preview-v1.json @@ -0,0 +1,19 @@ +{ + "localBranch": "feature/core", + "remote": "origin", + "remoteBranch": "feature/core", + "upstream": "origin/feature/core", + "commits": [ + { + "hash": "2222222222222222222222222222222222222222", + "shortHash": "2222222", + "parentHashes": ["1111111111111111111111111111111111111111"], + "authorName": "Lithe Developer", + "authorEmail": "developer@lithe.local", + "date": "2026/08/31 10:30", + "subject": "Add push preview", + "decorations": "HEAD -> feature/core" + } + ], + "hasMore": false +} diff --git a/shared/fixtures/git/write.json b/shared/fixtures/git/write.json index 1740d6ba5..dc53b04da 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,39 @@ "mode": "rebase" } }, + { + "operation": "push", + "payload": { + "gitReference": { + "fullName": "refs/heads/feature/core", + "shortName": "feature/core", + "kind": "local" + }, + "force": true, + "pushTags": "reachable" + } + }, + { + "operation": "pull", + "payload": { + "gitReference": { + "fullName": "refs/remotes/origin/feature/core", + "shortName": "origin/feature/core", + "kind": "remote" + }, + "mode": "rebase" + } + }, + { + "operation": "deleteRemoteBranch", + "payload": { + "gitReference": { + "fullName": "refs/remotes/team/origin/feature/core", + "shortName": "team/origin/feature/core", + "kind": "remote" + } + } + }, { "operation": "stashPush", "payload": { @@ -76,6 +129,36 @@ "mode": "merge" }, "errorCode": "invalid_request" + }, + { + "operation": "checkoutAndRebase", + "payload": { + "gitReference": { + "fullName": "refs/remotes/origin/feature/core", + "shortName": "feature/core", + "kind": "local" + } + }, + "errorCode": "invalid_request" + }, + { + "operation": "push", + "payload": { + "reference": "refs/heads/main", + "pushTags": "invalid" + }, + "errorCode": "invalid_request" + }, + { + "operation": "deleteRemoteBranch", + "payload": { + "gitReference": { + "fullName": "refs/heads/main", + "shortName": "main", + "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..0a4c66743 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 @@ -866,6 +915,29 @@ mod tests { ); } + #[test] + fn preserves_head_reference_for_complete_working_tree_path_diff() { + let (command, payload) = translate( + "git_diff_file", + json!({ + "repoPath": "C:/work", + "filePath": "src/main.rs", + "reference": "HEAD" + }), + ) + .unwrap(); + + assert_eq!(command, "git.diff"); + assert_eq!( + payload, + json!({ + "root": "C:/work", + "pathspecs": ["src/main.rs"], + "reference": "HEAD" + }) + ); + } + #[test] fn translates_untracked_diff_file_pathspec() { let (command, payload) = translate( @@ -904,6 +976,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/command-palette/components/command-palette.tsx b/windows/tauri/src/features/command-palette/components/command-palette.tsx index d888030b3..6f01c20d6 100644 --- a/windows/tauri/src/features/command-palette/components/command-palette.tsx +++ b/windows/tauri/src/features/command-palette/components/command-palette.tsx @@ -11,7 +11,8 @@ import { useFileSystemStore } from "@/features/file-system/stores/file-system.st import { LocalHistoryCommandContent } from "@/features/local-history/components/local-history-command"; import { OutlineCommandContent } from "@/features/outline/components/outline-command"; import { commitChanges } from "@/features/git/api/git-commits-api"; -import { fetchChanges, pullChanges, pushChanges } from "@/features/git/api/git-remotes-api"; +import { fetchChanges, pullChanges } from "@/features/git/api/git-remotes-api"; +import { showGitPushDialog } from "@/features/git/services/git-push-dialog-service"; import { discardAllChanges, stageAllFiles, @@ -315,7 +316,7 @@ const CommandPaletteContent = ({ commandPaletteInitialView }: CommandPaletteCont stageAllFiles, unstageAllFiles, commitChanges, - pushChanges, + showGitPushDialog, pullChanges, fetchChanges, discardAllChanges, diff --git a/windows/tauri/src/features/command-palette/constants/git-actions.tsx b/windows/tauri/src/features/command-palette/constants/git-actions.tsx index 8fb6af034..c78a78e82 100644 --- a/windows/tauri/src/features/command-palette/constants/git-actions.tsx +++ b/windows/tauri/src/features/command-palette/constants/git-actions.tsx @@ -29,7 +29,7 @@ interface GitActionsParams { stageAllFiles: (path: string) => Promise; unstageAllFiles: (path: string) => Promise; commitChanges: (path: string, message: string) => Promise; - pushChanges: (path: string) => Promise; + showGitPushDialog: (path: string) => Promise; pullChanges: (path: string) => Promise; fetchChanges: (path: string) => Promise; discardAllChanges: (path: string) => Promise; @@ -352,27 +352,14 @@ export const createGitActions = (params: GitActionsParams): Action[] => { description: "Push changes to remote", icon: , category: "Git", - action: async () => { + action: () => { if (!repoPath) { showToast({ message: t("git.noRepositoryOpen"), type: "error" }); onClose(); return; } - try { - showToast({ message: t("git.pushingChanges"), type: "info" }); - const result = await gitOperations.pushChanges(repoPath); - if (result.success) { - showToast({ message: t("git.changesPushed"), type: "success" }); - } else { - showToast({ - message: result.error || "Failed to push changes", - type: "error", - }); - } - } catch (error) { - showToast({ message: t("git.operationError", { error: String(error) }), type: "error" }); - } onClose(); + void gitOperations.showGitPushDialog(repoPath); }, }, { 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/editor/components/toolbar/breadcrumb.tsx b/windows/tauri/src/features/editor/components/toolbar/breadcrumb.tsx index d3dacfab8..d2ebc56db 100644 --- a/windows/tauri/src/features/editor/components/toolbar/breadcrumb.tsx +++ b/windows/tauri/src/features/editor/components/toolbar/breadcrumb.tsx @@ -197,8 +197,8 @@ export default function Breadcrumb({ return ( <> -
-
+
+
{showPath && showBreadcrumbPath ? ( <> {showFilePath ? ( @@ -220,7 +220,7 @@ export default function Breadcrumb({ ))} {extraLeftContent}
-
+
{defaultActions} {defaultActions && rightContent ?
: null} {rightContent} 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..4f6e8a749 --- /dev/null +++ b/windows/tauri/src/features/git/api/git-branches-api.test.ts @@ -0,0 +1,119 @@ +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", + force: false, + pushTags: "none", + }); + }); + + 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..99d38321e 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,10 @@ import { resolveRepositoryPathOrThrow, } from "./git-repo-api"; import type { GitReference } from "../types/git.types"; +import { referencePayload, type GitReferenceInput } from "./git-reference-payload"; +import { executeGitPush } from "./git-push-api"; -interface CheckoutResult { +export interface CheckoutResult { success: boolean; hasChanges: boolean; message: string; @@ -31,6 +33,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 +57,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 +79,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 +101,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 +118,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 +137,82 @@ 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 => { + await executeGitPush(repoPath, { reference: localBranchReference(branchName) }); +}; + +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..d8653d11b --- /dev/null +++ b/windows/tauri/src/features/git/api/git-commits-api.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import * as gitEvents from "../events/git-events"; + +let gitWriteResult = { output: "", exitCode: 0 }; +let gitWriteError: Error | null = null; +const emitGitChanged = spyOn(gitEvents, "emitGitChanged"); + +const invoke = mock(async (command: string, _args?: unknown): Promise => { + if (command === "git_discover_repo") return "C:/repo"; + if (command === "git.write") { + if (gitWriteError) throw gitWriteError; + 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(); + emitGitChanged.mockClear(); + gitWriteResult = { output: "", exitCode: 0 }; + gitWriteError = null; +}); + +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", + ); + expect(emitGitChanged).toHaveBeenLastCalledWith({ + repoPath: "C:/repo", + scopes: ["working-tree", "history", "refs"], + source: "commit", + }); + }); + + test("refreshes repository state when a history mutation rejects", async () => { + gitWriteError = new Error("cherry-pick stopped with conflicts"); + + await expect(cherryPickCommit("C:/repo", "d4")).rejects.toThrow( + "cherry-pick stopped with conflicts", + ); + expect(emitGitChanged).toHaveBeenLastCalledWith({ + repoPath: "C:/repo", + scopes: ["working-tree", "history", "refs"], + source: "cherry-pick-commit", + }); + }); +}); 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..cf44daeb6 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,37 @@ 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); + try { + const result = await tauriInvoke("git.write", { + repoPath: resolvedRepoPath, + ...payload, + }); + if (typeof result?.exitCode === "number" && result.exitCode !== 0) { + throw new Error(result.output?.trim() || "Git history operation failed"); + } + } finally { + // A rejected rewrite can leave conflicts or sequencer state behind. + emitGitChanged({ + repoPath: resolvedRepoPath, + scopes: ["working-tree", "history", "refs"], + source, + }); + } +}; + export const commitChanges = async (repoPath: string, message: string): Promise => { try { const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); @@ -24,6 +55,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 +122,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-diff-api.ts b/windows/tauri/src/features/git/api/git-diff-api.ts index 6be3f41dc..66cc2975f 100644 --- a/windows/tauri/src/features/git/api/git-diff-api.ts +++ b/windows/tauri/src/features/git/api/git-diff-api.ts @@ -1,5 +1,5 @@ import { invoke as tauriInvoke } from "@/platform/tauri-core"; -import type { GitDiff, GitDiffStat } from "../types/git.types"; +import type { GitDiff, GitDiffStat, GitReference } from "../types/git.types"; import { registerGitCacheInvalidator } from "../runtime/git-cache-registry"; import { runGitRead } from "../runtime/git-read-coordinator"; import { gitDiffCache } from "../utils/git-diff-cache"; @@ -8,6 +8,7 @@ import { resolveRepositoryForFile, resolveRepositoryPath, } from "./git-repo-api"; +import { referencePayload, toCoreGitReference } from "./git-reference-payload"; interface MultiFileDiffCacheEntry { diffs: GitDiff[]; @@ -206,6 +207,34 @@ export const getFileDiff = async ( } }; +export const getWorkingTreePathDiff = async ( + repoPath: string, + filePath: string, + untracked = false, +): Promise => { + if (untracked) { + return getUntrackedFileDiff(repoPath, filePath); + } + + try { + const resolved = await resolveRepositoryForFile(repoPath, filePath); + if (!resolved) return null; + + return await runGitRead(resolved.repoPath, `working-tree-path-diff:${resolved.filePath}`, () => + tauriInvoke("git_diff_file", { + repoPath: resolved.repoPath, + filePath: resolved.filePath, + reference: "HEAD", + }), + ); + } catch (error) { + if (!isNotGitRepositoryError(error) && !isNoDiffFoundError(error)) { + console.error("Failed to get working-tree path diff:", error); + } + return null; + } +}; + export const getUntrackedFileDiff = async ( repoPath: string, filePath: string, @@ -353,23 +382,62 @@ export const getRefDiff = async ( } }; -export const getReferenceWorkingTreeDiff = async ( +export const getTypedReferenceDiff = async ( + repoPath: string, + baseReference: GitReference, + targetReference: GitReference, +): Promise => { + try { + const resolvedRepoPath = await resolveRepositoryPath(repoPath); + if (!resolvedRepoPath) return null; + const cacheKey = `${resolvedRepoPath}:${baseReference.fullName}:${targetReference.fullName}`; + const cached = getMultiFileDiffCacheEntry(refDiffCache, cacheKey); + if (cached) return cached; + + const generation = getRepositoryCacheGeneration(resolvedRepoPath); + const diffs = await runGitRead( + resolvedRepoPath, + `typed-ref-diff:${baseReference.fullName}:${targetReference.fullName}`, + () => + tauriInvoke("git_ref_diff", { + repoPath: resolvedRepoPath, + gitReference: toCoreGitReference(baseReference), + targetGitReference: toCoreGitReference(targetReference), + pathspecs: ["."], + }), + ); + if (generation !== getRepositoryCacheGeneration(resolvedRepoPath)) { + return getTypedReferenceDiff(resolvedRepoPath, baseReference, targetReference); + } + setMultiFileDiffCacheEntry(refDiffCache, cacheKey, diffs); + return diffs; + } catch (error) { + if (!isNotGitRepositoryError(error)) { + console.error("Failed to compare Git references:", error); + } + return null; + } +}; + +export const getWorkingTreeRefDiff = async ( repoPath: string, - reference: string, + reference: GitReference | string, ): Promise => { try { const resolvedRepoPath = await resolveRepositoryPath(repoPath); if (!resolvedRepoPath) return null; - return await runGitRead(resolvedRepoPath, `reference-worktree-diff:${reference}`, () => - tauriInvoke("git_reference_worktree_diff", { + const fullName = typeof reference === "string" ? reference : reference.fullName; + return await runGitRead(resolvedRepoPath, `working-tree-ref-diff:${fullName}`, () => + tauriInvoke("git_working_tree_ref_diff", { repoPath: resolvedRepoPath, - reference, + ...referencePayload(reference), }), ); } catch (error) { - if (isNotGitRepositoryError(error)) return null; - console.error("Failed to compare reference with working tree:", error); - throw error; + if (!isNotGitRepositoryError(error)) { + console.error("Failed to compare reference with the working tree:", error); + } + return null; } }; diff --git a/windows/tauri/src/features/git/api/git-push-api.test.ts b/windows/tauri/src/features/git/api/git-push-api.test.ts new file mode 100644 index 000000000..a3c269e67 --- /dev/null +++ b/windows/tauri/src/features/git/api/git-push-api.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import * as gitEvents from "../events/git-events"; + +const invoke = mock(async (command: string): Promise => { + if (command === "git_discover_repo") return "C:/repo"; + if (command === "git.pushPreview") { + return { + localBranch: "feature/push", + remote: "origin", + remoteBranch: "feature/push", + upstream: "origin/feature/push", + commits: [ + { + hash: "2222222222222222222222222222222222222222", + shortHash: "2222222", + parentHashes: ["1111111111111111111111111111111111111111"], + authorName: "Lithe Test", + authorEmail: "test@lithe.local", + date: "2026/08/31 10:30", + subject: "Preview push", + decorations: "HEAD -> feature/push", + }, + ], + hasMore: false, + }; + } + return null; +}); +const emitGitChanged = spyOn(gitEvents, "emitGitChanged"); + +mock.module("@/platform/tauri-core", () => ({ invoke })); + +const { executeGitPush, getGitPushPreview } = await import("./git-push-api"); + +beforeEach(() => { + invoke.mockClear(); + emitGitChanged.mockClear(); +}); + +describe("Git push API", () => { + test("loads the resolved destination and maps Core commit fields", async () => { + const preview = await getGitPushPreview("C:/repo", { + fullName: "refs/heads/feature/push", + shortName: "feature/push", + kind: "local", + isCurrent: true, + }); + + expect(invoke).toHaveBeenCalledWith("git.pushPreview", { + repoPath: "C:/repo", + gitReference: { + fullName: "refs/heads/feature/push", + shortName: "feature/push", + kind: "local", + }, + }); + expect(preview.commits[0]).toMatchObject({ + message: "Preview push", + author: "Lithe Test", + email: "test@lithe.local", + }); + }); + + test("executes force-with-lease intent and reachable tags through git.write", async () => { + await executeGitPush("C:/repo", { + reference: "feature/push", + force: true, + pushTags: "reachable", + }); + + expect(invoke).toHaveBeenCalledWith("git.write", { + repoPath: "C:/repo", + operation: "push", + reference: "refs/heads/feature/push", + force: true, + pushTags: "reachable", + }); + expect(emitGitChanged).toHaveBeenCalledWith({ + repoPath: "C:/repo", + scopes: ["history", "refs", "remotes"], + source: "force-push", + }); + }); +}); diff --git a/windows/tauri/src/features/git/api/git-push-api.ts b/windows/tauri/src/features/git/api/git-push-api.ts new file mode 100644 index 000000000..1944b97a9 --- /dev/null +++ b/windows/tauri/src/features/git/api/git-push-api.ts @@ -0,0 +1,83 @@ +import { invoke as tauriInvoke } from "@/platform/tauri-core"; +import { emitGitChanged } from "../events/git-events"; +import type { + GitCommit, + GitPushPreview, + GitPushTagScope, +} from "../types/git.types"; +import { resolveRepositoryPathOrThrow } from "./git-repo-api"; +import { referencePayload, type GitReferenceInput } from "./git-reference-payload"; + +interface CoreGitCommit { + hash: string; + shortHash: string; + parentHashes: string[]; + authorName: string; + authorEmail: string; + date: string; + subject: string; + decorations: string; +} + +interface CoreGitPushPreview extends Omit { + commits: CoreGitCommit[]; +} + +export interface GitPushOptions { + reference?: GitReferenceInput; + force?: boolean; + pushTags?: GitPushTagScope; +} + +const localReference = (branch: string): string => + branch.startsWith("refs/heads/") ? branch : `refs/heads/${branch}`; + +const pushReferencePayload = (reference?: GitReferenceInput): Record => { + if (!reference) return {}; + return referencePayload(typeof reference === "string" ? localReference(reference) : reference); +}; + +const toGitCommit = (commit: CoreGitCommit): GitCommit => ({ + hash: commit.hash, + shortHash: commit.shortHash, + parentHashes: commit.parentHashes, + message: commit.subject, + author: commit.authorName, + email: commit.authorEmail, + date: commit.date, + decorations: commit.decorations, +}); + +export const getGitPushPreview = async ( + repoPath: string, + reference?: GitReferenceInput, +): Promise => { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + const preview = await tauriInvoke("git.pushPreview", { + repoPath: resolvedRepoPath, + ...pushReferencePayload(reference), + }); + return { + ...preview, + commits: preview.commits.map(toGitCommit), + }; +}; + +export const executeGitPush = async ( + repoPath: string, + options: GitPushOptions = {}, +): Promise => { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + await tauriInvoke("git.write", { + repoPath: resolvedRepoPath, + operation: "push", + ...pushReferencePayload(options.reference), + force: options.force ?? false, + pushTags: options.pushTags ?? "none", + }); + emitGitChanged({ + repoPath: resolvedRepoPath, + scopes: ["history", "refs", "remotes"], + source: options.force ? "force-push" : "push", + }); +}; 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..8aff6ac03 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 @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import * as gitEvents from "../events/git-events"; -import type { GitPullPreflight } from "../types/git.types"; +import type { GitPullPreflight, GitReference } from "../types/git.types"; const invoke = mock(async (_command: string, _args?: unknown): Promise => null); const emitGitChanged = spyOn(gitEvents, "emitGitChanged"); @@ -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,33 @@ 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, + ); + + const reference: GitReference = { + fullName: "refs/remotes/team/origin/feature/orders", + shortName: "team/origin/feature/orders", + kind: "remote", + isCurrent: false, + }; + await deleteRemoteBranch("C:/repo", reference); + + expect(invoke).toHaveBeenCalledWith("git.write", { + repoPath: "C:/repo", + operation: "deleteRemoteBranch", + gitReference: { + fullName: "refs/remotes/team/origin/feature/orders", + shortName: "team/origin/feature/orders", + kind: "remote", + }, + }); + 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..13f36ed40 100644 --- a/windows/tauri/src/features/git/api/git-remotes-api.ts +++ b/windows/tauri/src/features/git/api/git-remotes-api.ts @@ -1,11 +1,13 @@ import { invoke as tauriInvoke } from "@/platform/tauri-core"; -import type { GitPullPreflight, GitRemote, PullStrategy } from "../types/git.types"; +import type { GitPullPreflight, GitReference, GitRemote, PullStrategy } from "../types/git.types"; import { emitGitChanged } from "../events/git-events"; import { GitPullWorkflow } from "../hooks/git-pull-workflow"; import { runGitRead } from "../runtime/git-read-coordinator"; import { getBranches } from "./git-branches-api"; import { getGitHistory } from "./git-commits-api"; import { getOperationState } from "./git-integration-api"; +import { executeGitPush } from "./git-push-api"; +import { toCoreGitReference } from "./git-reference-payload"; import { getGitStatus } from "./git-status-api"; import { isNotGitRepositoryError, @@ -70,19 +72,30 @@ export const removeRemote = async (repoPath: string, name: string): Promise => { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + await tauriInvoke("git.write", { + repoPath: resolvedRepoPath, + operation: "deleteRemoteBranch", + gitReference: toCoreGitReference(reference), + }); + emitGitChanged({ + repoPath: resolvedRepoPath, + scopes: ["history", "refs", "remotes"], + source: "delete-remote-branch", + }); +}; + export const pushChanges = async ( repoPath: string, branch?: string, - remote: string = "origin", + _remote: string = "origin", ): Promise => { try { - const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); - await tauriInvoke("git_push", { repoPath: resolvedRepoPath, branch, remote }); - emitGitChanged({ - repoPath: resolvedRepoPath, - scopes: ["refs", "remotes"], - source: "push", - }); + await executeGitPush(repoPath, { reference: branch }); return { success: true }; } catch (error) { console.error("Failed to push changes:", error); diff --git a/windows/tauri/src/features/git/api/git-repo-api.test.ts b/windows/tauri/src/features/git/api/git-repo-api.test.ts index 9cefff172..9a8a9c5be 100644 --- a/windows/tauri/src/features/git/api/git-repo-api.test.ts +++ b/windows/tauri/src/features/git/api/git-repo-api.test.ts @@ -42,4 +42,30 @@ describe("resolveRepositoryForFile", () => { filePath: "src/main.ts", }); }); + + test("falls back to the active repository when a deleted file's directory is gone", async () => { + invoke.mockImplementation(async (_command, args) => { + const path = (args as { path: string }).path; + if (path === "D:/work/project/removed/directory") { + throw new Error("Workspace does not exist"); + } + return "D:/work/project"; + }); + + const result = await resolveRepositoryForFile( + "D:/work/project", + "removed/directory/Deleted.java", + ); + + expect(invoke).toHaveBeenNthCalledWith(1, "git_discover_repo", { + path: "D:/work/project/removed/directory", + }); + expect(invoke).toHaveBeenNthCalledWith(2, "git_discover_repo", { + path: "D:/work/project", + }); + expect(result).toEqual({ + repoPath: "D:/work/project", + filePath: "removed/directory/Deleted.java", + }); + }); }); diff --git a/windows/tauri/src/features/git/api/git-repo-api.ts b/windows/tauri/src/features/git/api/git-repo-api.ts index 66073db2d..993c0ed24 100644 --- a/windows/tauri/src/features/git/api/git-repo-api.ts +++ b/windows/tauri/src/features/git/api/git-repo-api.ts @@ -215,7 +215,23 @@ export async function resolveRepositoryForFile( filePath: string, ): Promise<{ repoPath: string; filePath: string } | null> { const absoluteFilePath = isAbsolutePath(filePath) ? filePath : joinPath(repoPath, filePath); - const discoveredRepo = await discoverRepo(parentPath(absoluteFilePath)); + let discoveredRepo: string | null; + try { + discoveredRepo = await discoverRepo(parentPath(absoluteFilePath)); + } catch (error) { + const fallbackRepo = await discoverRepo(repoPath); + const normalizedFallbackRepo = fallbackRepo ? normalizePath(fallbackRepo) : null; + const normalizedAbsoluteFile = normalizePath(absoluteFilePath); + const belongsToFallbackRepo = + normalizedFallbackRepo !== null && + (normalizedAbsoluteFile === normalizedFallbackRepo || + normalizedAbsoluteFile.startsWith(`${normalizedFallbackRepo}/`)); + + if (!belongsToFallbackRepo) { + throw error; + } + discoveredRepo = normalizedFallbackRepo; + } if (!discoveredRepo) { return null; 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..bf300758d --- /dev/null +++ b/windows/tauri/src/features/git/api/git-status-api.test.ts @@ -0,0 +1,100 @@ +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"); +const { getWorkingTreePathDiff } = await import("./git-diff-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/"], + }); + }); +}); + +describe("Git status review diffs", () => { + test("reviews a partially staged path against HEAD before selected-path commit", async () => { + await expect( + getWorkingTreePathDiff("C:/repo", "src/partially-staged.ts"), + ).resolves.toBeNull(); + + expect(invoke).toHaveBeenLastCalledWith("git_diff_file", { + repoPath: "C:/repo", + filePath: "src/partially-staged.ts", + reference: "HEAD", + }); + }); +}); 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..207e48d2b 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) { @@ -1071,11 +1116,13 @@ const GitDiffEditorStack = memo(function GitDiffEditorStack({ showPath={false} showDefaultActions={false} extraLeftContent={ -
+
{isWorkingTree && selectedDiffFile ? ( <> - {selectedFileName} + + {selectedFileName} + -
+
-
+
-
+
{t("git.current")} : null} + accessory={ + isCurrent ? {t("git.current")} : null + } action={ !isCurrent ? (
- {isCurrent ? {t("git.current")} : null} + {isCurrent ? ( + {t("git.current")} + ) : null} {isAdded ? {t("git.added")} : null} } @@ -1033,7 +1052,9 @@ function WorktreeRow({ "min-h-9", isCurrent ? "text-foreground" : "text-subtle-foreground hover:text-foreground", )} - accessory={isCurrent ? {t("git.current")} : null} + accessory={ + isCurrent ? {t("git.current")} : null + } /> ); } diff --git a/windows/tauri/src/features/git/components/git-commit-history.tsx b/windows/tauri/src/features/git/components/git-commit-history.tsx index 3bbfbd9b3..7f2e4653b 100644 --- a/windows/tauri/src/features/git/components/git-commit-history.tsx +++ b/windows/tauri/src/features/git/components/git-commit-history.tsx @@ -1,5 +1,13 @@ -import { FunnelIcon as Funnel } from "@/ui/icons"; +import { + ArrowCounterClockwiseIcon as Reset, + FunnelIcon as Funnel, + GitCommitIcon as CherryPick, + GitMergeIcon as Squash, + PencilIcon as Edit, + TrashIcon as Trash, +} from "@/ui/icons"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type React from "react"; import { writeSidebarResourceDragData } from "@/features/sidebar/utils/sidebar-resource-drag"; import { DropdownMenu, @@ -7,6 +15,9 @@ import { DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, + Dropdown, + useDropdownMenu, + type MenuItem, } from "@/ui/dropdown"; import { Spinner } from "@/ui/spinner"; import { Avatar } from "@/ui/avatar"; @@ -19,6 +30,13 @@ import { useTranslation } from "@/i18n/locale-provider"; import type { GitCommit } from "../types/git.types"; import { useGitStore } from "../stores/git.store"; import { getGitAuthorAvatarUrl } from "../utils/git-author-avatar"; +import { useGitHistoryMutations } from "../hooks/use-git-history-mutations"; +import { + isContiguousGitHistorySelection, + resolveGitHistoryContextSelection, + selectedCommitsInHistoryOrder, + updateGitHistorySelection, +} from "../utils/git-history-selection"; interface GitCommitHistoryProps { onViewCommitDiff?: (commitHash: string, filePath?: string) => void; @@ -29,7 +47,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 +79,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 +94,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..151395327 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"; @@ -15,23 +15,22 @@ import { ButtonGroup, ButtonGroupSeparator } from "@/ui/button-group"; import { Dropdown, type MenuItem } from "@/ui/dropdown"; import { SidebarComposerBody } from "@/ui/sidebar"; import Textarea from "@/ui/textarea"; -import { toast } from "sonner"; import { cn } from "@/utils/cn"; 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 { getConflictMarkerPaths } from "../api/git-integration-api"; -import { pushChanges, type GitRemoteActionResult } from "../api/git-remotes-api"; +import { commitSelectedChanges, getGitLog } from "../api/git-commits-api"; +import { getWorkingTreePathDiff } from "../api/git-diff-api"; +import { showGitPushDialog } from "../services/git-push-dialog-service"; 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 +38,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 +70,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 +104,27 @@ 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) => + getWorkingTreePathDiff(repoPath, file.path, file.status === "untracked"), + ), + ), ]); - 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 +141,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 +156,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 +188,9 @@ function normalizeGeneratedCommitMessage(message: string, mode: CommitMessageMod } const GitCommitPanel = ({ - stagedFilesCount, - stagedFiles, + selectedFiles, + commitMessage, + onCommitMessageChange, currentBranch, repoPath, ahead = 0, @@ -193,25 +198,31 @@ const GitCommitPanel = ({ onCommitSuccess, onPull, isPulling = false, + focusRequest = 0, }: GitCommitPanelProps) => { const { t } = useTranslation(); - const aiAutocompleteProvider = useSettingsStore( - (state) => state.settings.aiAutocompleteProvider, - ); + const aiAutocompleteProvider = useSettingsStore((state) => state.settings.aiAutocompleteProvider); const aiAutocompleteModelId = useSettingsStore((state) => state.settings.aiAutocompleteProvider === "custom" ? 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"); const [isGenerateModeMenuOpen, setIsGenerateModeMenuOpen] = useState(false); + const [isCommitActionMenuOpen, setIsCommitActionMenuOpen] = useState(false); const [remoteAction, setRemoteAction] = useState<"push" | null>(null); const [error, setError] = useState(null); const generateMenuAnchorRef = useRef(null); + const commitMenuAnchorRef = 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 +239,7 @@ const GitCommitPanel = ({ }, [commitMessage]); const handleGenerateCommitMessage = async () => { - if (!repoPath || stagedFilesCount === 0) return; + if (!repoPath || selectedFilesCount === 0) return; setError(null); const existingDraftHint = commitMessage.trim(); @@ -238,25 +249,23 @@ const GitCommitPanel = ({ const selectedText = await buildCommitMessageContext({ repoPath, currentBranch, - stagedFiles, + selectedFiles, existingDraftHint, }); - const { editedText } = await requestInlineEdit( - { - provider: aiAutocompleteProvider, - customProviderScope: "autocomplete", - model: aiAutocompleteModelId, - beforeSelection: "", - selectedText, - 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.", - filePath: getRepoLabel(repoPath), - languageId: "git-commit", - }, - ); + const { editedText } = await requestInlineEdit({ + provider: aiAutocompleteProvider, + customProviderScope: "autocomplete", + model: aiAutocompleteModelId, + beforeSelection: "", + selectedText, + afterSelection: "", + instruction: + commitMessageMode === "title" + ? "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", + }); const message = normalizeGeneratedCommitMessage(editedText, commitMessageMode); if (!message) { @@ -264,7 +273,7 @@ const GitCommitPanel = ({ return; } - setCommitMessage(message); + onCommitMessageChange(message); } catch (generationError) { if (generationError instanceof InlineEditError) { setError(generationError.message); @@ -276,8 +285,12 @@ const GitCommitPanel = ({ } }; - const handleCommit = async () => { - if (!repoPath || !commitMessage.trim() || stagedFilesCount === 0) return; + const handleCommit = async (pushAfterCommit = false) => { + if (selectedFilesCount === 0) { + setError(t("git.selectFilesToCommit")); + return; + } + if (!repoPath || !commitMessage.trim()) 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. @@ -287,68 +300,53 @@ const GitCommitPanel = ({ return; } - let markerPaths: string[]; - try { - markerPaths = await getConflictMarkerPaths(repoPath); - } catch (markerError) { - console.error("Failed to check staged files for conflict markers:", markerError); - setError(t("git.verifyConflictMarkersFailed")); - return; - } - if (markerPaths.length > 0) { - setError(t("git.conflictMarkersRemain", { paths: markerPaths.join(", ") })); - return; - } - setIsCommitting(true); 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(""); + if (pushAfterCommit) { + setRemoteAction("push"); + try { + await showGitPushDialog(repoPath); + } catch (pushError) { + setError(pushError instanceof Error ? pushError.message : t("git.pushFailed")); + } finally { + setRemoteAction(null); + } + } onCommitSuccess?.(); } else { setError(t("git.commitChangesFailed")); } } catch (error) { - setError(error instanceof Error ? error.message : t("ai.unknownError")); + setError( + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : t("ai.unknownError"), + ); } finally { setIsCommitting(false); } }; - const handlePush = async (run: () => Promise) => { + const handlePush = async () => { if (!repoPath) return; - let toastId: string | number | null = null; setRemoteAction("push"); setError(null); try { - toastId = toast.info(t("git.pushingChanges"), { - duration: 0, - }); - - const result = await run(); - if (result.success) { - toast.dismiss(toastId); - toast.success(t("git.pushedChanges")); - onCommitSuccess?.(); - return; - } - - const errorMessage = result.error || t("git.pushFailed"); - toast.dismiss(toastId); - toast.error(errorMessage); - setError(errorMessage); - } catch (remoteError) { - const errorMessage = - remoteError instanceof Error ? remoteError.message : t("git.pushFailed"); - if (toastId) toast.dismiss(toastId); - toast.error(errorMessage); - setError(errorMessage); + if (await showGitPushDialog(repoPath)) onCommitSuccess?.(); } finally { setRemoteAction(null); } @@ -362,8 +360,8 @@ const GitCommitPanel = ({ }; const isCommitDisabled = - !commitMessage.trim() || stagedFilesCount === 0 || isCommitting || isGenerating; - const isGenerateDisabled = stagedFilesCount === 0 || isGenerating || isCommitting; + selectedFilesCount === 0 || !commitMessage.trim() || isCommitting || isGenerating; + const isGenerateDisabled = selectedFilesCount === 0 || isGenerating || isCommitting; const hasRemoteChanges = ahead > 0 || behind > 0; const isRemoteActionLoading = remoteAction !== null; const composerButtonClassName = @@ -382,6 +380,18 @@ const GitCommitPanel = ({ onClick: () => setCommitMessageMode("body"), }, ]; + const commitActionItems: MenuItem[] = [ + { + id: "commit-and-push", + label: t("git.commitAndPush"), + icon: , + disabled: isCommitDisabled || isRemoteActionLoading || isPulling, + onClick: () => { + setIsCommitActionMenuOpen(false); + void handleCommit(true); + }, + }, + ]; return ( <> @@ -401,7 +411,7 @@ const GitCommitPanel = ({