diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings
index a0edb6228..3ca3d383b 100644
--- a/macos/Resources/en.lproj/Localizable.strings
+++ b/macos/Resources/en.lproj/Localizable.strings
@@ -1098,6 +1098,7 @@
"Could not open the remote folder: %@" = "Could not open the remote folder: %@";
"Could not prepare diagnostic logs. Try again." = "Could not prepare diagnostic logs. Try again.";
"Could not prepare the run configuration: %@" = "Could not prepare the run configuration: %@";
+"Could not prepare the selected changes." = "Could not prepare the selected changes.";
"Could not read %@." = "Could not read %@.";
"Could not read %@: %@" = "Could not read %@: %@";
"Could not read project indexing settings. Try reopening the project." = "Could not read project indexing settings. Try reopening the project.";
@@ -1491,6 +1492,7 @@
"Enter a ChangeList name (1–100 characters)." = "Enter a ChangeList name (1–100 characters).";
"Enter a JSON object of adapter arguments no larger than 256 KiB." = "Enter a JSON object of adapter arguments no larger than 256 KiB.";
"Enter a JVM debug port between 1 and 65535." = "Enter a JVM debug port between 1 and 65535.";
+"Enter a Squash message or use the default combined messages." = "Enter a Squash message or use the default combined messages.";
"Enter a branch name" = "Enter a branch name";
"Enter a branch name before publishing." = "Enter a branch name before publishing.";
"Enter a branch name." = "Enter a branch name.";
@@ -1541,6 +1543,7 @@
"Evaluating…" = "Evaluating…";
"Event" = "Event";
"Events over time" = "events over time";
+"Every Reword step needs a commit message." = "Every Reword step needs a commit message.";
"Every saved version of this project's files is deleted. Open files are not changed." = "Every saved version of this project's files is deleted. Open files are not changed.";
"Example" = "Example";
"Exception" = "Exception";
@@ -3962,6 +3965,7 @@
"Squash %lld commits into 1. Later commits in the affected history will be replayed." = "Squash %lld commits into 1. Later commits in the affected history will be replayed.";
"Squash Commits" = "Squash Commits";
"Squash Commits…" = "Squash Commits…";
+"Squash and Fixup need a preceding commit that is not dropped." = "Squash and Fixup need a preceding commit that is not dropped.";
"Squash and merge" = "Squash and merge";
"Squash → 1" = "Squash → 1";
"Stable (Recommended)" = "Stable (Recommended)";
@@ -4267,6 +4271,7 @@
"The clipboard image could not be read" = "The clipboard image could not be read";
"The clipboard image is empty" = "The clipboard image is empty";
"The clipboard image is larger than 100 MB" = "The clipboard image is larger than 100 MB";
+"The commit messages in this plan are not valid." = "The commit messages in this plan are not valid.";
"The configuration changed on disk. Refresh and try again." = "The configuration changed on disk. Refresh and try again.";
"The configuration could not be read. Check the selected directory and file permissions." = "The configuration could not be read. Check the selected directory and file permissions.";
"The configured build tool is not executable: %@" = "The configured build tool is not executable: %@";
@@ -5214,6 +5219,7 @@
"from JAVA_HOME" = "from JAVA_HOME";
"generated main" = "generated main";
"generated test" = "generated test";
+"git.changeTargets.prepareFailed" = "Could not prepare the selected changes.";
"git.changelists.invalidName" = "Use a unique, nonempty name of at most 100 characters.";
"git.diff.repositoryPreviewEmptyTitle" = "Repository Diff";
"git.diff.repositoryPreviewTitle" = "Repository Diff: %1$@";
@@ -5228,6 +5234,10 @@
"git.log.branchNameEmpty" = "Specify name for the new branch";
"git.log.branchNameHead" = "HEAD is a reserved keyword";
"git.log.olderCommitsFailed" = "Could not load older commits. Refresh history";
+"git.rebasePlan.issue.emptyReword" = "Every Reword step needs a commit message.";
+"git.rebasePlan.issue.emptySquash" = "Enter a Squash message or use the default combined messages.";
+"git.rebasePlan.issue.invalidMessage" = "The commit messages in this plan are not valid.";
+"git.rebasePlan.issue.missingPreceding" = "Squash and Fixup need a preceding commit that is not dropped.";
"git.rollback.confirm" = "Rollback";
"git.rollback.deleteAdded" = "Delete local copies of added files";
"git.rollback.include" = "Include in rollback";
diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings
index aadbe2cb9..7ab0f6f72 100644
--- a/macos/Resources/zh-Hans.lproj/Localizable.strings
+++ b/macos/Resources/zh-Hans.lproj/Localizable.strings
@@ -1098,6 +1098,7 @@
"Could not open the remote folder: %@" = "无法打开远程文件夹:%@";
"Could not prepare diagnostic logs. Try again." = "无法准备诊断日志,请重试。";
"Could not prepare the run configuration: %@" = "无法准备运行配置:%@";
+"Could not prepare the selected changes." = "无法准备所选更改。";
"Could not read %@." = "无法读取 %@。";
"Could not read %@: %@" = "无法读取 %@:%@";
"Could not read project indexing settings. Try reopening the project." = "无法读取项目索引设置,请重新打开项目后重试。";
@@ -1491,6 +1492,7 @@
"Enter a ChangeList name (1–100 characters)." = "请输入更改列表名称(1–100 个字符)。";
"Enter a JSON object of adapter arguments no larger than 256 KiB." = "请输入不超过 256 KiB 的适配器参数 JSON 对象。";
"Enter a JVM debug port between 1 and 65535." = "请输入 1 到 65535 之间的 JVM 调试端口。";
+"Enter a Squash message or use the default combined messages." = "请输入 Squash 信息,或使用默认的合并信息。";
"Enter a branch name" = "请输入分支名称";
"Enter a branch name before publishing." = "请输入分支名称后再发布。";
"Enter a branch name." = "请输入分支名称。";
@@ -1541,6 +1543,7 @@
"Evaluating…" = "正在计算…";
"Event" = "事件";
"Events over time" = "事件分布";
+"Every Reword step needs a commit message." = "每个 Reword 步骤都需要提交信息。";
"Every saved version of this project's files is deleted. Open files are not changed." = "此项目文件的所有已保存版本都会被删除,已打开的文件不受影响。";
"Example" = "示例";
"Exception" = "异常";
@@ -3962,6 +3965,7 @@
"Squash %lld commits into 1. Later commits in the affected history will be replayed." = "将 %lld 笔提交合并为 1 笔,并重放其后的受影响提交。";
"Squash Commits" = "合并所选提交";
"Squash Commits…" = "合并所选提交…";
+"Squash and Fixup need a preceding commit that is not dropped." = "Squash 和 Fixup 需要前面有一个未被丢弃的提交。";
"Squash and merge" = "压缩并合并";
"Squash → 1" = "合并为 1 笔";
"Stable (Recommended)" = "正式版(推荐)";
@@ -4267,6 +4271,7 @@
"The clipboard image could not be read" = "无法读取剪贴板中的图片";
"The clipboard image is empty" = "剪贴板中的图片为空";
"The clipboard image is larger than 100 MB" = "剪贴板中的图片超过 100 MB";
+"The commit messages in this plan are not valid." = "此计划中的提交信息无效。";
"The configuration changed on disk. Refresh and try again." = "磁盘上的配置已变化。请刷新后重试。";
"The configuration could not be read. Check the selected directory and file permissions." = "无法读取配置,请检查所选目录和文件权限。";
"The configured build tool is not executable: %@" = "配置的构建工具无法执行:%@";
@@ -5214,6 +5219,7 @@
"from JAVA_HOME" = "来自 JAVA_HOME";
"generated main" = "生成的主代码";
"generated test" = "生成的测试代码";
+"git.changeTargets.prepareFailed" = "无法准备所选更改。";
"git.changelists.invalidName" = "请输入不重名、非空且不超过 100 个字符的名称。";
"git.diff.repositoryPreviewEmptyTitle" = "仓库差异";
"git.diff.repositoryPreviewTitle" = "仓库差异: %1$@";
@@ -5228,6 +5234,10 @@
"git.log.branchNameEmpty" = "请指定新分支的名称";
"git.log.branchNameHead" = "HEAD 是保留关键字";
"git.log.olderCommitsFailed" = "无法加载更早的提交,点击刷新历史";
+"git.rebasePlan.issue.emptyReword" = "每个 Reword 步骤都需要提交信息。";
+"git.rebasePlan.issue.emptySquash" = "请输入 Squash 信息,或使用默认的合并信息。";
+"git.rebasePlan.issue.invalidMessage" = "此计划中的提交信息无效。";
+"git.rebasePlan.issue.missingPreceding" = "Squash 和 Fixup 需要前面有一个未被丢弃的提交。";
"git.rollback.confirm" = "回滚";
"git.rollback.deleteAdded" = "删除新增文件的本地副本";
"git.rollback.include" = "纳入回滚";
diff --git a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift
index 0ae011207..28e73b989 100644
--- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift
+++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift
@@ -806,6 +806,14 @@ struct RustGitOperations: GitOperations, Sendable {
core.cancel(operationID: operationID)
}
+ func corePlan(
+ _ command: GitCorePlanCommand,
+ _ request: Request
+ ) -> Response? {
+ let result: Result = core.executeResult(command: command.rawValue, payload: request)
+ return try? result.get()
+ }
+
func files(in commit: GitCommit, at rootURL: URL) -> [GitCommitFile]? {
core.gitCommitFiles(at: rootURL, commit: commit.hash)?.files.map { file in
GitCommitFile(status: file.status, path: file.path)
diff --git a/macos/Sources/Lithe/Views/Git/GitReferenceRows.swift b/macos/Sources/Lithe/Views/Git/GitReferenceRows.swift
index 5829e5af5..5b066b05e 100644
--- a/macos/Sources/Lithe/Views/Git/GitReferenceRows.swift
+++ b/macos/Sources/Lithe/Views/Git/GitReferenceRows.swift
@@ -57,8 +57,10 @@ enum GitReferenceRowsBuilder {
collapsedGroups: Set
) -> [GitReferenceRow] {
var rows: [GitReferenceRow] = []
+ guard let nodes = GitReferenceTreeNode.plan(for: references) else { return rows }
append(
- GitReferenceTreeNode.build(from: references),
+ nodes,
+ references: references,
kind: kind,
depth: 0,
collapsedGroups: collapsedGroups,
@@ -69,6 +71,7 @@ enum GitReferenceRowsBuilder {
private static func append(
_ nodes: [GitReferenceTreeNode],
+ references: [GitReference],
kind: GitReferenceKind,
depth: Int,
collapsedGroups: Set,
@@ -78,7 +81,8 @@ enum GitReferenceRowsBuilder {
// A node can be both: `feature` may be a branch and also the prefix
// of `feature/x`, in which case it emits a reference row and a group
// row at the same depth.
- if let reference = node.reference {
+ if let index = node.reference, references.indices.contains(index) {
+ let reference = references[index]
rows.append(
GitReferenceRow(
id: "reference:" + node.path,
@@ -104,6 +108,7 @@ enum GitReferenceRowsBuilder {
guard !isCollapsed else { continue }
append(
node.children,
+ references: references,
kind: kind,
depth: depth + 1,
collapsedGroups: collapsedGroups,
@@ -250,73 +255,37 @@ enum GitReferenceRowMenu {
}
}
-/// Intermediate tree used only to group references by their `/`-separated path
-/// before flattening.
-struct GitReferenceTreeNode: Identifiable {
+/// One grouped path segment planned by Core (`git.referenceTree`).
+///
+/// Core owns the `/` grouping and the sibling order; the view keeps expansion,
+/// indentation, and row identities.
+struct GitReferenceTreeNode: Identifiable, Decodable {
let path: String
let name: String
- let reference: GitReference?
+ /// Index into the references sent to Core; `nil` for a pure group.
+ let reference: Int?
let children: [GitReferenceTreeNode]
var id: String { path }
- static func build(from references: [GitReference]) -> [GitReferenceTreeNode] {
- let root = MutableGitReferenceTreeNode(name: "", path: "")
-
- for reference in references {
- let components = reference.shortName
- .split(separator: "/")
- .map(String.init)
- guard !components.isEmpty else { continue }
-
- var node = root
- var pathComponents: [String] = []
- for component in components {
- pathComponents.append(component)
- if node.children[component] == nil {
- node.children[component] = MutableGitReferenceTreeNode(
- name: component,
- path: pathComponents.joined(separator: "/")
- )
- }
- node = node.children[component]!
- }
- node.reference = reference
- }
-
- return makeNodes(from: root)
+ private struct Request: Encodable {
+ struct Reference: Encodable { let name: String }
+ let references: [Reference]
}
- private static func makeNodes(from node: MutableGitReferenceTreeNode) -> [GitReferenceTreeNode] {
- node.children.values
- .map { child in
- GitReferenceTreeNode(
- path: child.path,
- name: child.name,
- reference: child.reference,
- children: makeNodes(from: child)
- )
- }
- // Leaves before folders, then natural order, so the list is stable
- // across rebuilds of the unordered child dictionary.
- .sorted { lhs, rhs in
- if (lhs.reference != nil) != (rhs.reference != nil) {
- return lhs.reference != nil
- }
- return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
- }
+ private struct Plan: Decodable {
+ let nodes: [GitReferenceTreeNode]
}
-}
-private final class MutableGitReferenceTreeNode {
- let name: String
- let path: String
- var reference: GitReference?
- var children: [String: MutableGitReferenceTreeNode] = [:]
-
- init(name: String, path: String) {
- self.name = name
- self.path = path
+ /// Plans the tree of one kind of references. `nil` means Core rejected the
+ /// request, in which case the pane shows no rows rather than a guessed order.
+ static func plan(for references: [GitReference]) -> [GitReferenceTreeNode]? {
+ guard !references.isEmpty else { return [] }
+ let result: Result = RustCoreBridge().executeResult(
+ command: "git.referenceTree",
+ payload: Request(references: references.map { Request.Reference(name: $0.shortName) })
+ )
+ return try? result.get().nodes
}
}
diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift
index c67b25b64..74be963d2 100644
--- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift
+++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift
@@ -1182,17 +1182,21 @@ package final class GitFeatureModel: ObservableObject {
}
}
- /// Drops changes below a directory summary that is in the same set.
+ /// The changes one operation acts on (`git.normalizeChangeTargets`).
///
- /// Acting on the summary already acts on them. Acting again would write
- /// each file twice into a shelf patch, or fail once the summary's files
- /// have been cleaned.
- private func coveringUntrackedDirectoryContents(_ changes: [GitChange]) -> [GitChange] {
- let summaries = changes.filter(\.isUntrackedDirectory)
- guard !summaries.isEmpty else { return changes }
- return changes.filter { change in
- !summaries.contains { $0.containsUntrackedDescendant(change) }
+ /// Core drops changes listed below a selected untracked directory summary
+ /// of the same repository: acting on the summary already acts on them, and
+ /// acting again would write a file twice into a shelf patch or fail once the
+ /// summary's files are gone. `nil` means Core rejected the selection; the
+ /// caller reports it instead of acting on an unnormalized set.
+ private func operationTargets(_ changes: [GitChange]) async -> [GitChange]? {
+ guard !changes.isEmpty else { return [] }
+ let plan: GitChangeTargetsPlan? = await service.corePlan(.normalizeChangeTargets, GitChangeTargetsRequest(changes))
+ guard let plan, plan.indices.allSatisfy(changes.indices.contains) else {
+ notifyFailure(L10n.string("Could not prepare the selected changes."), reason: "operation_failed")
+ return nil
}
+ return plan.indices.map { changes[$0] }
}
/// True for a change listed only because its untracked summary is expanded.
@@ -1873,8 +1877,7 @@ package final class GitFeatureModel: ObservableObject {
}
package func discardChanges(_ changes: [GitChange]) async {
- let changes = coveringUntrackedDirectoryContents(changes)
- guard !changes.isEmpty else { return }
+ guard let changes = await operationTargets(changes), !changes.isEmpty else { return }
_ = await withGitChangeOperation(changes) {
var results: [GitService.CommandResult] = []
for change in changes {
@@ -1927,7 +1930,7 @@ package final class GitFeatureModel: ObservableObject {
}
let contextID = rollbackContextID
let workspace = rollbackWorkspaceURL
- let changes = coveringUntrackedDirectoryContents(selection.includedChanges)
+ guard let changes = await operationTargets(selection.includedChanges) else { return }
activeRollbackID = contextID
isRollingBackChanges = true
defer {
@@ -2199,7 +2202,7 @@ package final class GitFeatureModel: ObservableObject {
guard await activateChangelist(targetList), canPrepareCommitFiles(changes) else { return false }
let generation = workspaceCommitGeneration
let workspace = workspaceURLProvider?()?.standardizedFileURL
- let selected = coveringUntrackedDirectoryContents(changes)
+ guard let selected = await operationTargets(changes) else { return false }
let roots = Set(availableRepositoryRoots.map(\.standardizedFileURL) + changes.map { $0.repositoryRoot.standardizedFileURL }).sorted { $0.path < $1.path }
let selectedByRoot = Dictionary(grouping: selected) { $0.repositoryRoot.standardizedFileURL }
let request = GitCommitSelectionRequest(repositories: roots.map {
@@ -3304,12 +3307,12 @@ package final class GitFeatureModel: ObservableObject {
inspection.hasLoadedChanges else { return }
// Resolve captured rows against the latest inspection. Neither a stale
// confirmation nor a foreign GitChange may redirect a worktree action.
- let targets = coveringUntrackedDirectoryContents(inspection.changes.filter { current in
+ guard let targets = await operationTargets(inspection.changes.filter { current in
current.repositoryRoot.standardizedFileURL == worktree.url.standardizedFileURL
&& changes.contains(current)
&& (staged.map { current.canToggleStaging && ($0 ? (!current.isStaged || current.hasWorkingTreeChange) : current.isStaged) }
?? current.hasWorkingTreeChange)
- })
+ }) else { return }
guard !targets.isEmpty else { return }
let mutationID = UUID()
worktreeChangesMutationID = mutationID
diff --git a/macos/Sources/LitheGitModule/Application/GitInteractiveRebaseFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitInteractiveRebaseFeatureModel.swift
index 50b7dc5cd..5dace2d43 100644
--- a/macos/Sources/LitheGitModule/Application/GitInteractiveRebaseFeatureModel.swift
+++ b/macos/Sources/LitheGitModule/Application/GitInteractiveRebaseFeatureModel.swift
@@ -1,5 +1,6 @@
import Combine
import Foundation
+import LitheModuleAPI
/// Owns the editable todo plan and durable Core session; views never infer sequencer completion.
@MainActor
@@ -34,10 +35,35 @@ package final class GitInteractiveRebaseFeatureModel: ObservableObject {
package var isBusy: Bool { isLoading || isExecuting }
package var steps: [GitRebaseStep] { plan.steps }
package var outputCommitCount: Int { plan.outputCommitCount }
+ private var planTask: Task?
package var selectedStep: GitRebaseStep? { steps.first { $0.hash == selectedHash } }
- package var validationMessage: String? { plan.validationMessage }
+ /// Core's first problem with the draft, in the user's language.
+ package var validationMessage: String? {
+ switch plan.issue?.code {
+ case "missingPrecedingCommit": L10n.string("Squash and Fixup need a preceding commit that is not dropped.")
+ case "emptyRewordMessage": L10n.string("Every Reword step needs a commit message.")
+ case "emptySquashMessage": L10n.string("Enter a Squash message or use the default combined messages.")
+ case .some: L10n.string("The commit messages in this plan are not valid.")
+ case nil: nil
+ }
+ }
package var canStart: Bool {
- !isBusy && preview?.allowed == true && preview?.expectedState != nil && !steps.isEmpty && validationMessage == nil
+ !isBusy && preview?.allowed == true && preview?.expectedState != nil && !steps.isEmpty
+ && plan.isPlanned && plan.issue == nil
+ }
+
+ /// Asks Core to validate the draft and derive its messages. An answer for a
+ /// draft the user has since edited is dropped.
+ private func refreshPlan() {
+ planTask?.cancel()
+ let request = plan.previewRequest
+ guard !request.steps.isEmpty else { return }
+ let requestGeneration = generation
+ planTask = Task { [weak self, service] in
+ let answer: GitRebasePlanPreview? = await service.corePlan(.rebasePlanPreview, request)
+ guard let self, !Task.isCancelled, self.generation == requestGeneration, let answer else { return }
+ self.plan.apply(answer, for: request)
+ }
}
package func begin(at root: URL, baseRevision: String) {
@@ -67,6 +93,7 @@ package final class GitInteractiveRebaseFeatureModel: ObservableObject {
case .success(let preview):
self.preview = preview
self.plan = GitRebasePlanDraft(commits: preview.commits)
+ self.refreshPlan()
self.selectedHash = self.steps.first?.hash
case .failure(let error): self.errorMessage = error.message
}
@@ -79,21 +106,25 @@ package final class GitInteractiveRebaseFeatureModel: ObservableObject {
guard !isBusy else { return }
plan.setAction(action, for: hash)
selectedHash = hash
+ refreshPlan()
}
package func setMessage(_ message: String, for hash: String) {
guard !isBusy else { return }
plan.setMessage(message, for: hash)
+ refreshPlan()
}
package func useDefaultSquashMessage(for hash: String) {
guard !isBusy else { return }
plan.useDefaultSquashMessage(for: hash)
+ refreshPlan()
}
package func move(_ hash: String, by offset: Int) {
guard !isBusy else { return }
plan.move(hash, by: offset)
+ refreshPlan()
}
package func start() async {
@@ -184,6 +215,8 @@ package final class GitInteractiveRebaseFeatureModel: ObservableObject {
generation &+= 1
previewTask?.cancel()
previewTask = nil
+ planTask?.cancel()
+ planTask = nil
root = nil
baseRevision = nil
preview = nil
diff --git a/macos/Sources/LitheGitModule/Models/GitCorePlans.swift b/macos/Sources/LitheGitModule/Models/GitCorePlans.swift
new file mode 100644
index 000000000..dc60735ab
--- /dev/null
+++ b/macos/Sources/LitheGitModule/Models/GitCorePlans.swift
@@ -0,0 +1,60 @@
+import Foundation
+
+/// Pure Core Git planners. They never start Git or read a repository; the app
+/// collects facts, Core decides, and the app executes and presents the result.
+package enum GitCorePlanCommand: String, Sendable {
+ case normalizeChangeTargets = "git.normalizeChangeTargets"
+ case rebasePlanPreview = "git.rebasePlanPreview"
+}
+
+// MARK: - git.normalizeChangeTargets
+
+package struct GitChangeTargetsRequest: Encodable, Sendable {
+ package struct Target: Encodable, Sendable {
+ package let root: String
+ package let path: String
+ package let untrackedDirectory: Bool
+ }
+ package let targets: [Target]
+
+ package init(_ changes: [GitChange]) {
+ targets = changes.map {
+ Target(root: $0.repositoryRoot.standardizedFileURL.path, path: $0.path,
+ untrackedDirectory: $0.isUntrackedDirectory)
+ }
+ }
+}
+
+package struct GitChangeTargetsPlan: Decodable, Sendable {
+ package let indices: [Int]
+}
+
+// MARK: - git.rebasePlanPreview
+
+package struct GitRebasePreviewRequest: Encodable, Equatable, Sendable {
+ package struct Commit: Encodable, Equatable, Sendable {
+ package let hash: String
+ package let message: String
+ }
+ package let commits: [Commit]
+ package let steps: [GitRebaseStep]
+}
+
+/// Messages, start payload, and the first problem for one rebase draft.
+package struct GitRebasePlanPreview: Decodable, Sendable, Equatable {
+ package struct Issue: Decodable, Sendable, Equatable {
+ /// `missingPrecedingCommit`, `emptyRewordMessage`, `emptySquashMessage`, or `invalidMessage`.
+ package let code: String
+ package let hash: String?
+ }
+ package struct Row: Decodable, Sendable, Equatable {
+ package let hash: String
+ package let message: String
+ }
+ package let issue: Issue?
+ /// Draft rows with the message the editor shows for each.
+ package let steps: [Row]
+ /// Exact steps for `git.rebaseStart`.
+ package let startSteps: [GitRebaseStep]
+ package let outputCommitCount: Int
+}
diff --git a/macos/Sources/LitheGitModule/Models/GitExecutionJournal.swift b/macos/Sources/LitheGitModule/Models/GitExecutionJournal.swift
index 8bd5d671c..89d00e34b 100644
--- a/macos/Sources/LitheGitModule/Models/GitExecutionJournal.swift
+++ b/macos/Sources/LitheGitModule/Models/GitExecutionJournal.swift
@@ -16,6 +16,8 @@ package final class GitExecutionJournal: @unchecked Sendable {
private var entryIDs: [String: Set] = [:]
private var hidden: Set = []
private var omittedHistory = false
+ /// Core's budget from the latest `requestStarted`.
+ private var retention: GitJournalRetention?
package init() {}
@@ -48,8 +50,9 @@ package final class GitExecutionJournal: @unchecked Sendable {
entries.append(entry)
}
}
- if entries.count > 200 { omittedHistory = true; entries.removeFirst(entries.count - 200) }
- while entries.count > 1 && entries.reduce(0, { $0 + $1.output.count }) > 1_048_576 {
+ guard let retention else { return }
+ if entries.count > retention.maxRecords { omittedHistory = true; entries.removeFirst(entries.count - retention.maxRecords) }
+ while entries.count > 1 && entries.reduce(0, { $0 + $1.output.count }) > retention.maxTotalOutputCharacters {
omittedHistory = true
entries.removeFirst()
}
@@ -63,6 +66,7 @@ package final class GitExecutionJournal: @unchecked Sendable {
private func receiveLocked(_ event: GitExecutionEvent, root: URL?) -> Bool {
let operationID = event.operationId
if event.type == "requestStarted" {
+ if let budget = event.retention { retention = budget }
activeRequests.insert(operationID)
requestRoots[operationID] = root ?? event.workingDirectory.flatMap { $0.isEmpty ? nil : URL(fileURLWithPath: $0) }
return false
@@ -78,7 +82,7 @@ package final class GitExecutionJournal: @unchecked Sendable {
if hidden.remove(operationID) != nil { return wasRunning }
guard let context = contexts[operationID] else {
// A request cancelled before any command ran (a newer request superseded it) is not a failure.
- guard let error = event.error, !error.isCancellation,
+ guard let error = event.error, event.failureRecord == true,
let root = requestRoots[operationID] ?? root else { return wasRunning }
retain([GitConsoleEntry(workingDirectory: root, arguments: [], output: "", exitCode: -1,
state: .unconfirmed, operationErrorMessage: [error.message, error.details].compactMap { $0 }.joined(separator: "\n"))])
@@ -100,7 +104,7 @@ package final class GitExecutionJournal: @unchecked Sendable {
}
// Empty bookkeeping requests are not invented console commands.
guard event.type == "started" || contexts[operationID] != nil else { return false }
- let context = contexts[operationID] ?? GitExecutionContext(operationID: operationID)
+ let context = contexts[operationID] ?? GitExecutionContext(operationID: operationID, retention: retention)
contexts[operationID] = context
context.receive(event)
omittedHistory = omittedHistory || context.hasOmittedHistory
diff --git a/macos/Sources/LitheGitModule/Models/GitExecutionModels.swift b/macos/Sources/LitheGitModule/Models/GitExecutionModels.swift
index 5a039eaea..ce5c1919f 100644
--- a/macos/Sources/LitheGitModule/Models/GitExecutionModels.swift
+++ b/macos/Sources/LitheGitModule/Models/GitExecutionModels.swift
@@ -35,6 +35,14 @@ package struct GitExecutionEvent: Decodable, Sendable {
package var exitCode: Int32?
package var durationMilliseconds: Int?
package var error: Failure?
+ /// `requestStarted`: the console journal budget Core defines for every host.
+ package var retention: GitJournalRetention?
+ /// `finished`: the record's final state, decided by Core.
+ package var state: String?
+ /// `requestFinished`: records of this request whose exit was never observed.
+ package var unconfirmedInvocations: [Int]?
+ /// `requestFinished`: the failure needs its own record because no Git process started.
+ package var failureRecord: Bool?
package struct Failure: Decodable, Sendable {
package let code: String
@@ -46,6 +54,14 @@ package struct GitExecutionEvent: Decodable, Sendable {
}
}
+/// Console journal budget from Core, in character counts.
+package struct GitJournalRetention: Decodable, Equatable, Sendable {
+ package let maxRecords: Int
+ package let maxRecordOutputCharacters: Int
+ package let maxRecordOutputLines: Int
+ package let maxTotalOutputCharacters: Int
+}
+
/// The lock protects synchronous bridge delivery and cancellation across the
/// worker/UI boundary. Only bounded snapshots cross onto the main actor.
package final class GitExecutionContext: @unchecked Sendable {
@@ -62,10 +78,8 @@ package final class GitExecutionContext: @unchecked Sendable {
private var requestRoot: URL?
private var requestInvocationID: UUID?
private var omittedHistory = false
- private static let maxRecords = 200
- private static let maxStreamCharacters = 32_768
- private static let maxOutputLines = 2_000
- private static let maxTotalCharacters = 1_048_576
+ /// Core's budget, learned from `requestStarted`; nothing is trimmed before it arrives.
+ private var retention: GitJournalRetention?
private struct Record {
let invocationID: Int
@@ -93,14 +107,19 @@ package final class GitExecutionContext: @unchecked Sendable {
var duration: Int?
var error: String?
- mutating func appendOutput(_ text: String, stream: GitConsoleOutputStream) {
+ mutating func appendOutput(_ text: String, stream: GitConsoleOutputStream, retention: GitJournalRetention?) {
// Bound both text storage and rendered rows, including empty lines.
- let retained = String(text.suffix(GitExecutionContext.maxStreamCharacters - 1))
+ guard let retention else {
+ lines.append(GitConsoleOutputLine(stream: stream, text: text))
+ lineCharacters += text.count + 1
+ return
+ }
+ let retained = String(text.suffix(retention.maxRecordOutputCharacters - 1))
lines.append(GitConsoleOutputLine(stream: stream, text: retained))
lineCharacters += retained.count + 1
truncated = truncated || retained.count < text.count
- while lines.count > 1 && (lineCharacters > GitExecutionContext.maxStreamCharacters
- || lines.count > GitExecutionContext.maxOutputLines) {
+ while lines.count > 1 && (lineCharacters > retention.maxRecordOutputCharacters
+ || lines.count > retention.maxRecordOutputLines) {
lineCharacters -= lines.removeFirst().text.count + 1
truncated = true
}
@@ -118,7 +137,8 @@ package final class GitExecutionContext: @unchecked Sendable {
}
package init(operationID: String = UUID().uuidString, source: GitExecutionSource = .unknown,
- action: UserAction? = UserActionContext.current) {
+ action: UserAction? = UserActionContext.current, retention: GitJournalRetention? = nil) {
+ self.retention = retention
self.operationID = operationID
self.source = source
self.action = action
@@ -143,6 +163,7 @@ package final class GitExecutionContext: @unchecked Sendable {
/// Called with the lock held so each event publishes an atomic snapshot.
private func receiveLocked(_ event: GitExecutionEvent, at root: URL?) {
if event.type == "requestStarted" {
+ if let retention = event.retention { self.retention = retention }
requestRoot = root ?? event.workingDirectory.map { URL(fileURLWithPath: $0) }
requestInvocationID = nil
return
@@ -160,7 +181,8 @@ package final class GitExecutionContext: @unchecked Sendable {
if event.type == "requestFinished" {
defer { requestRoot = nil; requestInvocationID = nil }
challenges.removeAll()
- for index in records.indices where records[index].state == .running {
+ let unconfirmed = Set(event.unconfirmedInvocations ?? [])
+ for index in records.indices where unconfirmed.contains(records[index].invocationID) {
records[index].state = .unconfirmed
dirty = true
}
@@ -169,10 +191,10 @@ package final class GitExecutionContext: @unchecked Sendable {
if let index = records.lastIndex(where: { $0.id == requestInvocationID }) {
records[index].error = message
dirty = true
- } else if let root = requestRoot, !error.isCancellation {
+ } else if let root = requestRoot, event.failureRecord == true {
// A context can span several Core requests. Keep a preflight
// failure separate from an earlier request's executed command.
- // A cancellation before any command ran has nothing to report.
+ // Core decides which failures need a record of their own.
appendRecord(Record(invocationID: -1, root: root, arguments: [],
source: source, state: .unconfirmed, exitCode: -1, error: message))
}
@@ -198,30 +220,33 @@ package final class GitExecutionContext: @unchecked Sendable {
switch event.type {
case "output":
records[index].phase = event.progressDetails ?? records[index].phase
- let text = GitConsoleRedactor.redact(event.text ?? "")
+ // Core redacts event text once; the host never scrubs it again.
+ let text = event.text ?? ""
if event.progress == true {
records[index].progress = text
} else {
if event.stream == "stderr" { records[index].stderr += text + "\n" }
else { records[index].stdout += text + "\n" }
- records[index].appendOutput(text, stream: event.stream == "stderr" ? .standardError : .standardOutput)
+ records[index].appendOutput(text, stream: event.stream == "stderr" ? .standardError : .standardOutput, retention: retention)
records[index].progress = nil
}
records[index].truncated = records[index].truncated || event.truncated == true
- if records[index].stdout.count > Self.maxStreamCharacters {
- records[index].stdout = String(records[index].stdout.suffix(Self.maxStreamCharacters))
- records[index].truncated = true
- }
- if records[index].stderr.count > Self.maxStreamCharacters {
- records[index].stderr = String(records[index].stderr.suffix(Self.maxStreamCharacters))
- records[index].truncated = true
- }
- while records.count > 1 && records.reduce(0, { $0 + $1.stdout.count + $1.stderr.count + $1.lineCharacters }) > Self.maxTotalCharacters {
- omittedHistory = true
- records.removeFirst()
+ if let retention {
+ if records[index].stdout.count > retention.maxRecordOutputCharacters {
+ records[index].stdout = String(records[index].stdout.suffix(retention.maxRecordOutputCharacters))
+ records[index].truncated = true
+ }
+ if records[index].stderr.count > retention.maxRecordOutputCharacters {
+ records[index].stderr = String(records[index].stderr.suffix(retention.maxRecordOutputCharacters))
+ records[index].truncated = true
+ }
+ while records.count > 1 && records.reduce(0, { $0 + $1.stdout.count + $1.stderr.count + $1.lineCharacters }) > retention.maxTotalOutputCharacters {
+ omittedHistory = true
+ records.removeFirst()
+ }
}
case "finished":
- records[index].state = event.exitCode == nil ? .unconfirmed : .completed
+ records[index].state = event.state == "completed" ? .completed : .unconfirmed
records[index].exitCode = event.exitCode ?? -1
records[index].expectedExit = event.expectedExit ?? false
records[index].duration = event.durationMilliseconds
@@ -234,7 +259,7 @@ package final class GitExecutionContext: @unchecked Sendable {
private func appendRecord(_ record: Record) {
records.append(record)
- if records.count > Self.maxRecords { omittedHistory = true; records.removeFirst(records.count - Self.maxRecords) }
+ if let maxRecords = retention?.maxRecords, records.count > maxRecords { omittedHistory = true; records.removeFirst(records.count - maxRecords) }
dirty = true
}
diff --git a/macos/Sources/LitheGitModule/Models/GitRebasePlanDraft.swift b/macos/Sources/LitheGitModule/Models/GitRebasePlanDraft.swift
index 74a78ff96..527dd52be 100644
--- a/macos/Sources/LitheGitModule/Models/GitRebasePlanDraft.swift
+++ b/macos/Sources/LitheGitModule/Models/GitRebasePlanDraft.swift
@@ -1,51 +1,54 @@
import Foundation
-/// Local todo presentation separates predicted squash text from an explicit user override.
+/// The editable todo list. Core (`git.rebasePlanPreview`) validates it and
+/// derives every message; the draft only keeps the user's order, actions, and
+/// explicit message overrides, and shows the last preview Core returned.
package struct GitRebasePlanDraft: Sendable {
package private(set) var steps: [GitRebaseStep]
+ private let commits: [GitHistoryRewriteCommit]
private let originalMessages: [String: String]
private var rewordMessages: [String: String] = [:]
private var squashMessages: [String: String] = [:]
+ private var preview: GitRebasePlanPreview?
package init(commits: [GitHistoryRewriteCommit] = []) {
+ self.commits = commits
originalMessages = Dictionary(uniqueKeysWithValues: commits.map { ($0.hash, $0.message) })
steps = commits.map { GitRebaseStep(hash: $0.hash, action: .pick, message: $0.message) }
}
- package var wireSteps: [GitRebaseStep] {
- steps.map { step in
- let message: String?
- switch step.action {
- case .reword: message = step.message
- case .squash: message = squashMessages[step.hash]
- case .pick, .edit, .fixup, .drop: message = nil
- }
- return GitRebaseStep(hash: step.hash, action: step.action, message: message)
- }
+ /// The draft as Core receives it: only explicit overrides carry a message.
+ package var previewRequest: GitRebasePreviewRequest {
+ GitRebasePreviewRequest(
+ commits: commits.map { .init(hash: $0.hash, message: $0.message) },
+ steps: steps.map { step in
+ switch step.action {
+ case .reword: GitRebaseStep(hash: step.hash, action: .reword, message: rewordMessages[step.hash])
+ case .squash: GitRebaseStep(hash: step.hash, action: .squash, message: squashMessages[step.hash])
+ default: GitRebaseStep(hash: step.hash, action: step.action)
+ }
+ })
}
- package var outputCommitCount: Int { steps.filter { [.pick, .reword, .edit].contains($0.action) }.count }
-
- package var validationMessage: String? {
- var hasPrecedingCommit = false
- for step in steps {
- if step.action == .drop { continue }
- if step.action == .squash || step.action == .fixup {
- if !hasPrecedingCommit { return "Squash and Fixup need a preceding commit that is not dropped." }
- } else {
- hasPrecedingCommit = true
- }
- if step.action == .reword, (step.message ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- return "Every Reword step needs a commit message."
- }
- if step.action == .squash, let custom = squashMessages[step.hash],
- custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- return "Enter a Squash message or use the default combined messages."
- }
+ /// Shows Core's answer for `request`; a stale answer for an older draft is ignored.
+ package mutating func apply(_ plan: GitRebasePlanPreview, for request: GitRebasePreviewRequest) {
+ guard request == previewRequest else { return }
+ preview = plan
+ for index in steps.indices {
+ if let row = plan.steps.first(where: { $0.hash == steps[index].hash }) { steps[index].message = row.message }
}
- return nil
}
+ /// Exact steps for `git.rebaseStart`; empty until Core has answered for the current draft.
+ package var wireSteps: [GitRebaseStep] { preview?.startSteps ?? [] }
+
+ package var outputCommitCount: Int { preview?.outputCommitCount ?? 0 }
+
+ /// True once Core has validated the current draft.
+ package var isPlanned: Bool { preview != nil }
+
+ package var issue: GitRebasePlanPreview.Issue? { preview?.issue }
+
package func subject(for hash: String) -> String {
(originalMessages[hash] ?? hash).components(separatedBy: "\n").first ?? hash
}
@@ -53,7 +56,7 @@ package struct GitRebasePlanDraft: Sendable {
package mutating func setAction(_ action: GitRebaseAction, for hash: String) {
guard let index = steps.firstIndex(where: { $0.hash == hash }) else { return }
steps[index].action = action
- rebuildMessages()
+ invalidate()
}
package mutating func setMessage(_ message: String, for hash: String) {
@@ -63,38 +66,29 @@ package struct GitRebasePlanDraft: Sendable {
case .squash: squashMessages[hash] = message
default: return
}
- rebuildMessages()
+ invalidate()
}
package mutating func useDefaultSquashMessage(for hash: String) {
squashMessages[hash] = nil
- rebuildMessages()
+ invalidate()
}
package mutating func move(_ hash: String, by offset: Int) {
guard let index = steps.firstIndex(where: { $0.hash == hash }), steps.indices.contains(index + offset) else { return }
steps.swapAt(index, index + offset)
- rebuildMessages()
+ invalidate()
}
- private mutating func rebuildMessages() {
- var precedingMessage: String?
+ /// Edits show the user's text immediately; Core's derivation replaces the rest when it answers.
+ private mutating func invalidate() {
+ preview = nil
for index in steps.indices {
let hash = steps[index].hash
- let original = originalMessages[hash] ?? ""
switch steps[index].action {
- case .pick, .edit:
- steps[index].message = original
- precedingMessage = original
- case .reword:
- steps[index].message = rewordMessages[hash] ?? original
- precedingMessage = steps[index].message
- case .squash:
- let predicted = [precedingMessage, original].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: "\n\n")
- steps[index].message = squashMessages[hash] ?? predicted
- precedingMessage = steps[index].message
- case .fixup, .drop:
- steps[index].message = original
+ case .reword: steps[index].message = rewordMessages[hash] ?? originalMessages[hash]
+ case .squash: if let custom = squashMessages[hash] { steps[index].message = custom }
+ default: steps[index].message = originalMessages[hash]
}
}
}
diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift
index 1e45e931a..48ebce816 100644
--- a/macos/Sources/LitheGitModule/Services/GitService.swift
+++ b/macos/Sources/LitheGitModule/Services/GitService.swift
@@ -123,6 +123,12 @@ package protocol GitOperations: Sendable {
operationID: String
) -> GitHistoryLocateResult?
func closeHistoryCursor(at rootURL: URL, cursor: String) -> Bool
+ /// Runs one pure Core planner. Planners never start Git or read a
+ /// repository; `nil` means Core rejected the request.
+ func corePlan(
+ _ command: GitCorePlanCommand,
+ _ request: Request
+ ) -> Response?
func cancel(operationID: String) -> Bool
func files(in commit: GitCommit, at rootURL: URL) -> [GitCommitFile]?
@@ -240,6 +246,10 @@ package protocol GitOperations: Sendable {
}
package extension GitOperations {
+ func corePlan(
+ _ command: GitCorePlanCommand,
+ _ request: Request
+ ) -> Response? { nil }
func workspaceStatusPlan(roots: [String], previousRoots: [String], outcomes: [GitWorkspaceStatusOutcome]) -> GitWorkspaceStatusPlan? { nil }
func resolveFileContext(fileURL: URL, relativeTo rootURL: URL) -> GitFileContext? { nil }
func changelistProjection(_ request: GitChangelistCoreRequest) -> GitChangelistCoreProjection? { nil }
@@ -1004,6 +1014,15 @@ package struct GitService: Sendable {
}
}
+ /// Runs a pure Core planner off the main actor.
+ func corePlan(
+ _ command: GitCorePlanCommand,
+ _ request: Request,
+ operationName: String = #function
+ ) async -> Response? {
+ await read(operationName: operationName) { $0.corePlan(command, request) }
+ }
+
func branchNamePreview(
rawName: String,
requireName: Bool,
diff --git a/macos/Tests/LitheGitModuleTests/GitExecutionTests.swift b/macos/Tests/LitheGitModuleTests/GitExecutionTests.swift
index 4f88435da..336100a2a 100644
--- a/macos/Tests/LitheGitModuleTests/GitExecutionTests.swift
+++ b/macos/Tests/LitheGitModuleTests/GitExecutionTests.swift
@@ -2,6 +2,10 @@ import Foundation
@testable import LitheGitModule
import Testing
+/// Core's journal budget as `requestStarted` carries it.
+private let coreRetention = GitJournalRetention(
+ maxRecords: 200, maxRecordOutputCharacters: 32_768, maxRecordOutputLines: 2_000, maxTotalOutputCharacters: 1_048_576)
+
struct GitExecutionTests {
@Test
func contextKeepsPreflightFailuresSeparateFromEarlierRequests() throws {
@@ -15,14 +19,15 @@ struct GitExecutionTests {
context.receive(GitExecutionEvent(operationId: "workflow", type: "requestStarted"), at: root)
context.receive(GitExecutionEvent(operationId: "workflow", type: "started", invocationId: 1,
workingDirectory: root.path, arguments: ["config", "pull.rebase", "true"]))
- context.receive(GitExecutionEvent(operationId: "workflow", type: "finished", invocationId: 1, exitCode: 0))
- context.receive(GitExecutionEvent(operationId: "workflow", type: "requestFinished"))
+ context.receive(GitExecutionEvent(operationId: "workflow", type: "finished", invocationId: 1, exitCode: 0, state: "completed"))
+ context.receive(GitExecutionEvent(operationId: "workflow", type: "requestFinished", unconfirmedInvocations: [], failureRecord: false))
let original = try #require(context.drainSnapshot()?.first)
let otherRoot = URL(fileURLWithPath: "/other-workspace")
context.receive(GitExecutionEvent(operationId: "workflow", type: "requestStarted"), at: otherRoot)
let failure = GitExecutionEvent(operationId: "workflow", type: "requestFinished",
- error: .init(code: "process_start_failed", message: "Could not inspect Git executable", details: "Missing executable"))
+ error: .init(code: "process_start_failed", message: "Could not inspect Git executable", details: "Missing executable"),
+ unconfirmedInvocations: [], failureRecord: true)
context.receive(failure)
let entries = try #require(context.drainSnapshot())
#expect(entries.count == 2 && context.hasEntries)
@@ -41,14 +46,15 @@ struct GitExecutionTests {
let context = GitExecutionContext(operationID: "workflow", source: .user)
context.receive(GitExecutionEvent(operationId: "workflow", type: "requestStarted"), at: URL(fileURLWithPath: "/workspace"))
context.receive(GitExecutionEvent(operationId: "workflow", type: "requestFinished",
- error: .init(code: "cancelled", message: "Operation was cancelled")))
+ error: .init(code: "cancelled", message: "Operation was cancelled"), unconfirmedInvocations: [], failureRecord: false))
#expect(context.drainSnapshot() == nil, "A superseded request is not a failed command")
#expect(!context.hasEntries)
// A different failure on the same context is still reported.
context.receive(GitExecutionEvent(operationId: "workflow", type: "requestStarted"), at: URL(fileURLWithPath: "/workspace"))
context.receive(GitExecutionEvent(operationId: "workflow", type: "requestFinished",
- error: .init(code: "process_failed", message: "Git process cleanup did not complete within its deadline")))
+ error: .init(code: "process_failed", message: "Git process cleanup did not complete within its deadline"),
+ unconfirmedInvocations: [], failureRecord: true))
#expect(context.drainSnapshot()?.last?.operationErrorMessage == "Git process cleanup did not complete within its deadline")
}
@@ -56,10 +62,11 @@ struct GitExecutionTests {
func contextPreflightFailureHistoryRemainsBounded() {
let context = GitExecutionContext(operationID: "workflow")
for index in 0..<201 {
- context.receive(GitExecutionEvent(operationId: "workflow", type: "requestStarted"),
+ context.receive(GitExecutionEvent(operationId: "workflow", type: "requestStarted", retention: coreRetention),
at: URL(fileURLWithPath: "/workspace"))
context.receive(GitExecutionEvent(operationId: "workflow", type: "requestFinished",
- error: .init(code: "process_start_failed", message: "Failure \(index)")))
+ error: .init(code: "process_start_failed", message: "Failure \(index)"),
+ unconfirmedInvocations: [], failureRecord: true))
}
let entries = context.drainSnapshot()
#expect(entries?.count == 200 && context.hasOmittedHistory)
@@ -103,9 +110,10 @@ struct GitExecutionTests {
let journal = GitExecutionJournal()
for index in 0..<201 {
let id = "preflight-\(index)"
- journal.receive(GitExecutionEvent(operationId: id, type: "requestStarted"), at: URL(fileURLWithPath: "/workspace"))
+ journal.receive(GitExecutionEvent(operationId: id, type: "requestStarted", retention: coreRetention), at: URL(fileURLWithPath: "/workspace"))
journal.receive(GitExecutionEvent(operationId: id, type: "requestFinished",
- error: .init(code: "process_start_failed", message: "Could not inspect Git executable")))
+ error: .init(code: "process_start_failed", message: "Could not inspect Git executable"),
+ unconfirmedInvocations: [], failureRecord: true))
}
#expect(journal.snapshot.count == 200)
#expect(journal.hasOmittedHistory)
@@ -148,8 +156,8 @@ struct GitExecutionTests {
for id in ["publish", "stage", "branch", "commit"] {
journal.receive(GitExecutionEvent(operationId: id, type: "output", invocationId: 1,
stream: "stdout", text: id))
- journal.receive(GitExecutionEvent(operationId: id, type: "finished", invocationId: 1, exitCode: 0))
- journal.receive(GitExecutionEvent(operationId: id, type: "requestFinished"))
+ journal.receive(GitExecutionEvent(operationId: id, type: "finished", invocationId: 1, exitCode: 0, state: "completed"))
+ journal.receive(GitExecutionEvent(operationId: id, type: "requestFinished", unconfirmedInvocations: [], failureRecord: false))
}
let entries = journal.snapshot
#expect(entries.map(\.arguments) == [["add"], ["commit"], ["checkout"], ["push"]])
@@ -174,7 +182,7 @@ struct GitExecutionTests {
journal.receive(GitExecutionEvent(operationId: "new", type: "started", invocationId: 1,
workingDirectory: "/workspace", arguments: ["checkout"]))
journal.receive(GitExecutionEvent(operationId: "new", type: "requestFinished",
- error: .init(code: "cancelled", message: "Operation was cancelled")))
+ error: .init(code: "cancelled", message: "Operation was cancelled"), unconfirmedInvocations: [1], failureRecord: false))
#expect(journal.snapshot.last?.state == .unconfirmed)
#expect(journal.snapshot.last?.operationErrorMessage == "Operation was cancelled")
}
@@ -283,6 +291,7 @@ struct GitExecutionTests {
@Test
func consoleBoundsEmptyRowsAndPreservesLatestStreamOrder() throws {
let context = GitExecutionContext(operationID: "operation")
+ context.receive(GitExecutionEvent(operationId: "operation", type: "requestStarted", retention: coreRetention))
context.receive(GitExecutionEvent(operationId: "operation", type: "started", invocationId: 1,
workingDirectory: "/workspace", arguments: ["fetch"]))
for _ in 0..<2_100 {
@@ -325,7 +334,7 @@ struct GitExecutionTests {
#expect(progress?.progressText == "Receiving: 50%")
#expect(progress?.standardError == "")
try receive(#"{"operationId":"operation","type":"output","invocationId":1,"stream":"stderr","text":"updated remote","progress":false}"#)
- try receive(#"{"operationId":"operation","type":"finished","invocationId":1,"exitCode":0,"durationMilliseconds":42}"#)
+ try receive(#"{"operationId":"operation","type":"finished","invocationId":1,"exitCode":0,"state":"completed","durationMilliseconds":42}"#)
let finished = context.drainSnapshot()?.last
#expect(finished?.succeeded == true)
#expect(finished?.durationMilliseconds == 42)
@@ -354,6 +363,7 @@ struct GitExecutionTests {
@Test
func unrelatedRequestsAndExcessOutputStayBounded() {
let context = GitExecutionContext(operationID: "operation")
+ context.receive(GitExecutionEvent(operationId: "operation", type: "requestStarted", retention: coreRetention))
context.receive(GitExecutionEvent(operationId: "other", type: "started", invocationId: 1, workingDirectory: "/wrong", arguments: ["fetch"]))
#expect(context.drainSnapshot() == nil)
context.receive(GitExecutionEvent(operationId: "operation", type: "started", invocationId: 1, workingDirectory: "/workspace", arguments: ["fetch"]))
@@ -370,12 +380,13 @@ struct GitConsolePresentationTests {
@Test
func omittedCommandsAreDistinguishedFromReversibleOutputFolds() {
let journal = GitExecutionJournal()
+ journal.receive(GitExecutionEvent(operationId: "budget", type: "requestStarted", retention: coreRetention))
for index in 0..<205 {
let id = "query-\(index)"
journal.receive(GitExecutionEvent(operationId: id, type: "started", invocationId: 1,
workingDirectory: "/workspace", arguments: ["status"]))
- journal.receive(GitExecutionEvent(operationId: id, type: "finished", invocationId: 1, exitCode: 0))
- journal.receive(GitExecutionEvent(operationId: id, type: "requestFinished"))
+ journal.receive(GitExecutionEvent(operationId: id, type: "finished", invocationId: 1, exitCode: 0, state: "completed"))
+ journal.receive(GitExecutionEvent(operationId: id, type: "requestFinished", unconfirmedInvocations: [], failureRecord: false))
}
#expect(journal.snapshot.count == 200)
#expect(journal.hasOmittedHistory)
@@ -413,7 +424,7 @@ struct GitConsolePresentationTests {
context.receive(GitExecutionEvent(operationId: "query", type: "started", invocationId: 1,
workingDirectory: "/workspace", arguments: ["config", "--get", "remote.origin.skipFetchAll"]))
context.receive(GitExecutionEvent(operationId: "query", type: "finished", invocationId: 1,
- expectedExit: true, exitCode: 1))
+ expectedExit: true, exitCode: 1, state: "completed"))
let entry = try #require(context.drainSnapshot()?.last)
#expect(entry.succeeded)
#expect(entry.exitCode == 1)
@@ -439,7 +450,7 @@ struct GitConsolePresentationTests {
for (id, command) in [(1, "fetch"), (2, "for-each-ref")] {
context.receive(GitExecutionEvent(operationId: "transfer", type: "started", invocationId: id,
workingDirectory: "/workspace", arguments: [command]))
- context.receive(GitExecutionEvent(operationId: "transfer", type: "finished", invocationId: id, exitCode: 0))
+ context.receive(GitExecutionEvent(operationId: "transfer", type: "finished", invocationId: id, exitCode: 0, state: "completed"))
}
context.receive(GitExecutionEvent(operationId: "transfer", type: "remoteResult", remote: "origin",
succeeded: true, invocationId: 1))
diff --git a/macos/Tests/LitheGitModuleTests/GitInteractiveRebaseTests.swift b/macos/Tests/LitheGitModuleTests/GitInteractiveRebaseTests.swift
index efa22fd17..232300152 100644
--- a/macos/Tests/LitheGitModuleTests/GitInteractiveRebaseTests.swift
+++ b/macos/Tests/LitheGitModuleTests/GitInteractiveRebaseTests.swift
@@ -3,48 +3,63 @@ import Foundation
import Testing
struct GitInteractiveRebaseTests {
+ private struct PlanFixture: Decodable {
+ struct Case: Decodable {
+ let name: String
+ let steps: [GitRebaseStep]
+ let response: GitRebasePlanPreview
+ }
+ struct Commit: Decodable { let hash: String; let message: String }
+ let commits: [Commit]
+ let cases: [Case]
+ var rewriteCommits: [GitHistoryRewriteCommit] {
+ commits.map { GitHistoryRewriteCommit(hash: $0.hash, parents: [], message: $0.message) }
+ }
+ }
+
+ private func planFixture() throws -> PlanFixture {
+ let repository = URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent().deletingLastPathComponent()
+ .deletingLastPathComponent().deletingLastPathComponent()
+ let data = try Data(contentsOf: repository.appendingPathComponent("shared/fixtures/git/rebase-plan-preview-v1.json"))
+ return try JSONDecoder().decode(PlanFixture.self, from: data)
+ }
+
@Test
- func defaultSquashAfterEditIsNotSentAsAStaleMessageOverride() {
- var plan = GitRebasePlanDraft(commits: [
- GitHistoryRewriteCommit(hash: "a", parents: ["base"], message: "A\n\nFirst body"),
- GitHistoryRewriteCommit(hash: "b", parents: ["a"], message: "B\n\nSecond body"),
- GitHistoryRewriteCommit(hash: "c", parents: ["b"], message: "C")
- ])
- plan.setAction(.edit, for: "a")
- plan.setAction(.squash, for: "b")
- plan.setAction(.fixup, for: "c")
- #expect(plan.steps[1].message == "A\n\nFirst body\n\nB\n\nSecond body")
- // Core must combine the then-current Edit message, not this predicted text.
- #expect(plan.wireSteps[1].message == nil)
- #expect(plan.outputCommitCount == 1)
- plan.setMessage("Custom combined\n\nKeep my full body", for: "b")
- plan.move("b", by: 1)
- plan.setAction(.pick, for: "b")
- plan.setAction(.squash, for: "b")
- #expect(plan.wireSteps.last?.message == "Custom combined\n\nKeep my full body")
- plan.useDefaultSquashMessage(for: "b")
- #expect(plan.wireSteps.last?.message == nil)
- #expect(plan.steps.last?.message == "A\n\nFirst body\n\nB\n\nSecond body")
+ func draftSendsOnlyExplicitOverridesAndShowsCoreDerivedMessages() throws {
+ let fixture = try planFixture()
+ let sample = try #require(fixture.cases.first)
+ var plan = GitRebasePlanDraft(commits: fixture.rewriteCommits)
+ for step in sample.steps { plan.setAction(step.action, for: step.hash) }
+ // The shared fixture owns the derivation; Core must receive no predicted text.
+ let request = plan.previewRequest
+ #expect(request.steps.map(\.hash) == sample.steps.map(\.hash))
+ #expect(request.steps.allSatisfy { $0.message == nil })
+ #expect(!plan.isPlanned && plan.wireSteps.isEmpty)
+ plan.apply(sample.response, for: request)
+ #expect(plan.isPlanned)
+ #expect(plan.wireSteps == sample.response.startSteps)
+ #expect(plan.outputCommitCount == sample.response.outputCommitCount)
+ for row in sample.response.steps {
+ #expect(plan.steps.first { $0.hash == row.hash }?.message == row.message)
+ }
}
@Test
- func defaultSquashPredictionFollowsEarlierRewordsAndIgnoresFixupMessages() {
- var plan = GitRebasePlanDraft(commits: [
- GitHistoryRewriteCommit(hash: "a", parents: ["base"], message: "A"),
- GitHistoryRewriteCommit(hash: "b", parents: ["a"], message: "Discarded fixup message"),
- GitHistoryRewriteCommit(hash: "c", parents: ["b"], message: "C")
- ])
- plan.setAction(.reword, for: "a")
- plan.setAction(.fixup, for: "b")
- plan.setAction(.squash, for: "c")
- plan.setMessage("A revised\n\nComplete body", for: "a")
- #expect(plan.steps.last?.message == "A revised\n\nComplete body\n\nC")
- #expect(plan.wireSteps.last?.message == nil)
- #expect(plan.validationMessage == nil)
- plan.move("b", by: -1)
- #expect(plan.validationMessage != nil)
- plan.move("b", by: 1)
- #expect(plan.validationMessage == nil)
+ func editingTheDraftInvalidatesCoreAnswersAndIgnoresStaleOnes() throws {
+ let fixture = try planFixture()
+ let sample = try #require(fixture.cases.first)
+ var plan = GitRebasePlanDraft(commits: fixture.rewriteCommits)
+ for step in sample.steps { plan.setAction(step.action, for: step.hash) }
+ let stale = plan.previewRequest
+ let squash = try #require(sample.steps.first { $0.action == .squash })
+ plan.setMessage("Custom combined", for: squash.hash)
+ #expect(!plan.isPlanned)
+ #expect(plan.previewRequest.steps.first { $0.hash == squash.hash }?.message == "Custom combined")
+ plan.apply(sample.response, for: stale)
+ #expect(!plan.isPlanned && plan.wireSteps.isEmpty)
+ plan.useDefaultSquashMessage(for: squash.hash)
+ #expect(plan.previewRequest == stale)
}
@Test
diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift
index 03fd04351..27c2fca1a 100644
--- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift
+++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift
@@ -6887,6 +6887,27 @@ private struct TestGitOperations: GitOperations {
GitWorkspaceStatusPlan.Entry(root: root, source: succeeded.contains(root) ? "current" : (previous.contains(root) ? "previous" : "missing"))
}, confirmedRoots: roots.filter { succeeded.contains($0) }, failedRoots: roots.filter { !succeeded.contains($0) })
}
+ /// Test double for `git.normalizeChangeTargets`; Core's own tests and the shared
+ /// fixture (`change-targets-v1.json`) define the real rule.
+ func corePlan(
+ _ command: GitCorePlanCommand,
+ _ request: Request
+ ) -> Response? {
+ guard command == .normalizeChangeTargets,
+ let data = try? JSONEncoder().encode(request),
+ let targets = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["targets"] as? [[String: Any]]
+ else { return nil }
+ let indices = targets.indices.filter { index in
+ let target = targets[index]
+ return !targets.contains { summary in
+ summary["untrackedDirectory"] as? Bool == true
+ && summary["root"] as? String == target["root"] as? String
+ && summary["path"] as? String != target["path"] as? String
+ && (target["path"] as? String ?? "").hasPrefix(summary["path"] as? String ?? "\u{0}")
+ }
+ }
+ return try? JSONDecoder().decode(Response.self, from: JSONSerialization.data(withJSONObject: ["indices": indices]))
+ }
func changelistProjection(_ request: GitChangelistCoreRequest) -> GitChangelistCoreProjection? {
if let changelistProjectionHandler { return changelistProjectionHandler(request) }
return GitChangelistCoreTestSupport.project(request)
diff --git a/rust/lithe-core/src/diagnostics/redaction.rs b/rust/lithe-core/src/diagnostics/redaction.rs
index b7837f304..c8fd06359 100644
--- a/rust/lithe-core/src/diagnostics/redaction.rs
+++ b/rust/lithe-core/src/diagnostics/redaction.rs
@@ -34,8 +34,10 @@ static SENSITIVE_ASSIGNMENT: LazyLock = LazyLock::new(|| {
.expect("valid regex")
});
+// Query names hosts once redacted on their own before displaying Git output;
+// Core is now the only redaction pass for console events.
static SENSITIVE_QUERY_PARAMETER: LazyLock = LazyLock::new(|| {
- Regex::new(r"(?i)([?&](?:access_token|api_key|token|key|auth))=[^\s]+").expect("valid regex")
+ Regex::new(r"(?i)([?&](?:access_token|api_key|apikey|token|key|auth|authorization|client_secret|password|passwd|secret))=[^\s]+").expect("valid regex")
});
static CREDENTIAL_SHAPE: LazyLock = LazyLock::new(|| {
@@ -123,6 +125,26 @@ mod tests {
);
}
+ /// Git console hosts no longer redact event text themselves; every query
+ /// name they used to scrub must be covered here.
+ #[test]
+ fn redacts_every_query_credential_hosts_used_to_scrub() {
+ for name in [
+ "client_secret",
+ "passwd",
+ "apikey",
+ "authorization",
+ "auth",
+ "token",
+ ] {
+ assert_eq!(
+ redact(&format!("https://example.com/r.git?x=1&{name}=s3cr3t")),
+ format!("https://example.com/r.git?x=1&{name}="),
+ "{name}"
+ );
+ }
+ }
+
#[test]
fn redacts_sensitive_key_value_pairs() {
assert_eq!(redact("password=hunter2"), "password=");
diff --git a/rust/lithe-core/src/git/change_targets.rs b/rust/lithe-core/src/git/change_targets.rs
new file mode 100644
index 000000000..301f3f8a5
--- /dev/null
+++ b/rust/lithe-core/src/git/change_targets.rs
@@ -0,0 +1,140 @@
+//! Normalizes the change targets one Git operation acts on.
+//!
+//! A wholly untracked directory can be reported as one summary (`dir/`) whose
+//! contents the user may list on demand. Selecting the summary together with
+//! some of its listed files must act on each file exactly once: a shelf patch
+//! would otherwise record the file twice, and a delete or rollback would fail
+//! once the summary's files are gone. Hosts send the selection they captured;
+//! Core decides which entries remain operation targets.
+
+use crate::protocol::{CoreError, ErrorCode};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashSet;
+
+/// Upper bound for one selection; larger requests are host bugs, not user input.
+const MAX_TARGETS: usize = 100_000;
+
+/// One selected change identity in the order the host captured it.
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+struct Target {
+ /// Host-normalized repository root that owns `path`; only equality is used.
+ root: String,
+ /// Repository-relative path. A summary directory ends with `/`.
+ path: String,
+ /// True for a Core summary of a wholly untracked directory.
+ #[serde(default)]
+ untracked_directory: bool,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+struct Request {
+ targets: Vec,
+}
+
+/// Positions of the retained targets, ascending, so hosts keep their own rows.
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct Response {
+ indices: Vec,
+}
+
+fn invalid(message: &str) -> CoreError {
+ CoreError::new(ErrorCode::InvalidRequest, message)
+}
+
+/// Handles `git.normalizeChangeTargets`.
+///
+/// A target is dropped when a summary of the same repository is a strict
+/// ancestor of its path. Everything else, including duplicates the host chose
+/// to send, keeps its position; acting on a summary already acts on the
+/// listed files below it.
+pub(crate) fn command(payload: Value) -> Result {
+ let request: Request = serde_json::from_value(payload).map_err(|error| {
+ invalid("Invalid Git change target request").with_details(error.to_string())
+ })?;
+ if request.targets.len() > MAX_TARGETS {
+ return Err(invalid("Too many Git change targets"));
+ }
+ if request
+ .targets
+ .iter()
+ .any(|target| target.root.is_empty() || target.path.is_empty())
+ {
+ return Err(invalid("Git change targets need a root and a path"));
+ }
+ if request
+ .targets
+ .iter()
+ .any(|target| target.untracked_directory && !target.path.ends_with('/'))
+ {
+ return Err(invalid("An untracked directory summary must end with /"));
+ }
+ let summaries = request
+ .targets
+ .iter()
+ .filter(|target| target.untracked_directory)
+ .map(|target| (target.root.as_str(), target.path.as_str()))
+ .collect::>();
+ let indices = request
+ .targets
+ .iter()
+ .enumerate()
+ .filter(|(_, target)| !below_summary(target, &summaries))
+ .map(|(index, _)| index)
+ .collect();
+ Ok(serde_json::to_value(Response { indices }).expect("change target indices encode"))
+}
+
+/// Walks the path's own ancestors, so the cost is bounded by its depth rather
+/// than by the number of summaries in the selection.
+fn below_summary(target: &Target, summaries: &HashSet<(&str, &str)>) -> bool {
+ target
+ .path
+ .match_indices('/')
+ .map(|(index, _)| &target.path[..=index])
+ .any(|prefix| {
+ prefix.len() < target.path.len() && summaries.contains(&(target.root.as_str(), prefix))
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use serde_json::{json, Value};
+
+ fn call(payload: Value) -> Value {
+ serde_json::from_str(&crate::execute_json(
+ &json!({"id":"targets", "command":"git.normalizeChangeTargets", "payload":payload})
+ .to_string(),
+ ))
+ .unwrap()
+ }
+
+ #[test]
+ fn shared_fixture_cases_keep_only_operation_targets() {
+ let fixture: Value = serde_json::from_str(include_str!(
+ "../../../../shared/fixtures/git/change-targets-v1.json"
+ ))
+ .unwrap();
+ for case in fixture["cases"].as_array().unwrap() {
+ let response = call(case["request"].clone());
+ assert_eq!(response["ok"], true, "{}: {response}", case["name"]);
+ assert_eq!(
+ response["data"]["indices"], case["indices"],
+ "{}",
+ case["name"]
+ );
+ }
+ for case in fixture["invalid"].as_array().unwrap() {
+ let response = call(case["request"].clone());
+ assert_eq!(response["ok"], false, "{}", case["name"]);
+ assert_eq!(
+ response["error"]["code"], "invalid_request",
+ "{}",
+ case["name"]
+ );
+ }
+ }
+}
diff --git a/rust/lithe-core/src/git/execution_events.rs b/rust/lithe-core/src/git/execution_events.rs
index 9ba45c460..d82c35e23 100644
--- a/rust/lithe-core/src/git/execution_events.rs
+++ b/rust/lithe-core/src/git/execution_events.rs
@@ -2,6 +2,13 @@
//!
//! Events contain sanitized diagnostics, never command input or environment.
//! Native process ownership belongs to the `lithe-git-host` adapter.
+//!
+//! Hosts keep a console journal of these events. The journal's record states
+//! and retention budget are part of this contract: `requestStarted` carries
+//! [`JOURNAL_RETENTION`], `finished` carries the record's final state, and
+//! `requestFinished` names the records that never confirmed an exit and
+//! whether a failure needs a record of its own. Hosts store, lock, and
+//! throttle; they do not derive states or redact again.
use crate::protocol::CoreError;
use serde::Serialize;
@@ -12,6 +19,28 @@ use std::time::Instant;
const MAX_LINE_BYTES: usize = 16 * 1024;
const MAX_DIAGNOSTIC_BYTES: usize = 512 * 1024;
const MAX_OUTPUT_EVENTS: usize = 4096;
+
+/// Console journal retention shared by every host, in host string length units.
+#[derive(Clone, Copy, Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct JournalRetention {
+ /// Records kept across all requests; the oldest are dropped first.
+ pub(crate) max_records: usize,
+ /// Output kept per record; earlier lines are dropped first.
+ pub(crate) max_record_output_characters: usize,
+ /// Output lines kept per record, including empty lines.
+ pub(crate) max_record_output_lines: usize,
+ /// Output kept across all records before the oldest record is dropped.
+ pub(crate) max_total_output_characters: usize,
+}
+
+/// One budget for both products, chosen for the lower resident memory.
+pub(crate) const JOURNAL_RETENTION: JournalRetention = JournalRetention {
+ max_records: 200,
+ max_record_output_characters: 32_768,
+ max_record_output_lines: 2_000,
+ max_total_output_characters: 1_048_576,
+};
/// A serialized event consumer, invoked synchronously during its owning request.
pub type EventSink = Arc;
@@ -32,9 +61,16 @@ struct Event<'a> {
)]
/// Process start is evidence of a spawned child; failure may have no exit code.
enum Kind<'a> {
- RequestStarted,
+ RequestStarted {
+ retention: JournalRetention,
+ },
RequestFinished {
error: Option,
+ /// Records of this request still running; hosts mark them `unconfirmed`.
+ unconfirmed_invocations: Vec,
+ /// The failure happened before any Git process of this request started,
+ /// so it is shown as its own record instead of on an executed command.
+ failure_record: bool,
},
Started {
source: super::console::Source,
@@ -61,6 +97,8 @@ enum Kind<'a> {
exit_code: Option,
duration_milliseconds: u128,
error: Option<&'a CoreError>,
+ /// `completed` with an exit code; `unconfirmed` when the exit was not observed.
+ state: &'static str,
},
}
@@ -71,6 +109,10 @@ struct Context {
next_invocation: u64,
/// Transfer summaries remain attached to the transfer despite subsequent inspection queries.
last_transfer: Option,
+ /// Processes started by the current request.
+ request_invocations: usize,
+ /// Started processes of the current request without a `finished` event.
+ open_invocations: std::collections::BTreeSet,
}
thread_local! { static CURRENT: RefCell
))}
- {invalidPlan ? (
+ {plan?.issue ? (
{t("git.rebasePlan.invalidPlan")}
@@ -368,17 +362,17 @@ function GitRebaseDialog({
{step.action === "reword" || step.action === "squash" ? (
updateStep(index, { message })}
disabled={status === "working"}
/>
) : (
- {splitGitCommitMessage(plan.messages.get(step.hash) ?? "").title}
+ {splitGitCommitMessage(messages.get(step.hash) ?? "").title}
- {plan.messages.get(step.hash)}
+ {messages.get(step.hash)}
)}
@@ -505,7 +499,7 @@ function GitRebaseDialog({
disabled={
status !== "ready" || !preview.allowed || !preview.expectedState || invalidPlan
}
- onClick={() => void run(() => startGitRebase(request.repoPath, preview, plan.steps))}
+ onClick={() => void run(() => startGitRebase(request.repoPath, preview, plan?.startSteps ?? []))}
>
{t("git.rebasePlan.start")}
diff --git a/windows/tauri/src/features/git/components/log/git-reference-tree.test.tsx b/windows/tauri/src/features/git/components/log/git-reference-tree.test.tsx
index 17fdb2007..2a9fccf2e 100644
--- a/windows/tauri/src/features/git/components/log/git-reference-tree.test.tsx
+++ b/windows/tauri/src/features/git/components/log/git-reference-tree.test.tsx
@@ -1,5 +1,8 @@
-import { expect, test } from "bun:test";
-import { renderToStaticMarkup } from "react-dom/server";
+import { afterEach, beforeEach, expect, spyOn, test } from "bun:test";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import * as tauriCore from "@/platform/tauri-core";
+import { installHappyDom } from "@/test-utils/happy-dom";
import { LocaleProvider } from "@/i18n/locale-provider";
import type { GitReference } from "../../types/git.types";
import { GitReferenceTree } from "./git-reference-tree";
@@ -12,27 +15,70 @@ const localReference = (shortName: string, isCurrent = false): GitReference => (
isCurrent,
});
-const renderTree = (
+// Stands in for `git.referenceTree`; the grouping rules are tested in Core.
+let restoreDom: (() => void) | undefined;
+let invokeSpy: ReturnType | undefined;
+beforeEach(() => {
+ restoreDom = installHappyDom();
+ globalThis.ResizeObserver ??= class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ };
+ invokeSpy = spyOn(tauriCore, "invoke").mockImplementation((async (
+ command: string,
+ args: { references?: { name: string }[] },
+ ) => {
+ // Other commands (repository colors) stay unresolved.
+ if (command !== "git.referenceTree") return new Promise(() => {});
+ return {
+ nodes: (args.references ?? []).map((item, index) => ({
+ name: item.name,
+ path: item.name,
+ reference: index,
+ children: [],
+ })),
+ };
+ }) as never);
+});
+afterEach(() => {
+ invokeSpy?.mockRestore();
+ restoreDom?.();
+});
+
+const renderTree = async (
props: Partial[0]> &
Pick[0], "repoPath" | "references">,
-) =>
- renderToStaticMarkup(
-
- {}}
- onReferenceAction={() => {}}
- onSetUpstream={() => {}}
- onManageRemotes={() => {}}
- onFetch={() => {}}
- onNavigateToHead={() => {}}
- {...props}
- />
- ,
+) => {
+ const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
+ environment.IS_REACT_ACT_ENVIRONMENT = true;
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ await act(async () =>
+ root.render(
+
+ {}}
+ onReferenceAction={() => {}}
+ onSetUpstream={() => {}}
+ onManageRemotes={() => {}}
+ onFetch={() => {}}
+ onNavigateToHead={() => {}}
+ {...props}
+ />
+ ,
+ ),
);
+ const markup = host.innerHTML;
+ await act(async () => root.unmount());
+ host.remove();
+ return markup;
+};
-test("single repository keeps the flat reference sections", () => {
- const markup = renderTree({
+test("single repository keeps the flat reference sections", async () => {
+ const markup = await renderTree({
repoPath: "C:/repo-a",
references: [localReference("main", true)],
repositoryPaths: ["C:/repo-a"],
@@ -44,10 +90,10 @@ test("single repository keeps the flat reference sections", () => {
expect(markup).not.toContain("background-color");
});
-test("multi-repository workspaces render neutral dots while Core loads", () => {
+test("multi-repository workspaces render neutral dots while Core loads", async () => {
const repoA = { ...localReference("main", true), repositoryPath: "C:/repo-a" };
const repoB = { ...localReference("develop"), repositoryPath: "C:/repo-b" };
- const markup = renderTree({
+ const markup = await renderTree({
repoPath: "C:/repo-a",
references: [repoA],
repositoryPaths: ["C:/repo-a", "C:/repo-b"],
@@ -66,10 +112,10 @@ test("multi-repository workspaces render neutral dots while Core loads", () => {
expect(markup).not.toMatch(/(? {
+test("multiple repositories group each section set under a repository header", async () => {
const repoA = { ...localReference("main", true), repositoryPath: "C:/repo-a" };
const repoB = { ...localReference("develop"), repositoryPath: "C:/repo-b" };
- const markup = renderTree({
+ const markup = await renderTree({
repoPath: "C:/repo-a",
references: [repoA],
repositoryPaths: ["C:/repo-a", "C:/repo-b"],
@@ -86,10 +132,10 @@ test("multiple repositories group each section set under a repository header", (
expect(markup).toContain("develop");
});
-test("exposes reference actions only for the active repository group", () => {
+test("exposes reference actions only for the active repository group", async () => {
const repoA = { ...localReference("main", true), repositoryPath: "C:/repo-a" };
const repoB = { ...localReference("develop"), repositoryPath: "C:/repo-b" };
- const markup = renderTree({
+ const markup = await renderTree({
repoPath: "C:/repo-a",
references: [repoA],
repositoryPaths: ["C:/repo-a", "C:/repo-b"],
@@ -132,8 +178,8 @@ const renderWorktreeWorkspace = () => {
});
};
-test("shows linked worktree repositories and the toggle by default", () => {
- const markup = renderWorktreeWorkspace();
+test("shows linked worktree repositories and the toggle by default", async () => {
+ const markup = await renderWorktreeWorkspace();
expect(markup).toContain("op-platform");
expect(markup).toContain("apps-sealed");
@@ -142,10 +188,10 @@ test("shows linked worktree repositories and the toggle by default", () => {
expect(markup).toContain('aria-pressed="true"');
});
-test("disables the worktree toggle when no repository is nested", () => {
+test("disables the worktree toggle when no repository is nested", async () => {
const repoA = { ...localReference("main", true), repositoryPath: "C:/repo-a" };
const repoB = { ...localReference("develop"), repositoryPath: "C:/repo-b" };
- const markup = renderTree({
+ const markup = await renderTree({
repoPath: "C:/repo-a",
references: [repoA],
repositoryPaths: ["C:/repo-a", "C:/repo-b"],
diff --git a/windows/tauri/src/features/git/components/log/git-reference-tree.tsx b/windows/tauri/src/features/git/components/log/git-reference-tree.tsx
index 39d2e5311..e4d627adb 100644
--- a/windows/tauri/src/features/git/components/log/git-reference-tree.tsx
+++ b/windows/tauri/src/features/git/components/log/git-reference-tree.tsx
@@ -51,8 +51,9 @@ import {
type GitReferenceAction,
} from "../../utils/git-reference-actions";
import {
- buildGitReferenceTree,
collectGitReferenceGroupIds,
+ useGitReferenceTrees,
+ type GitReferenceTreeSource,
countGitReferencesByKind,
filterGitLogReferences,
filterSelectableGitReferences,
@@ -848,7 +849,7 @@ export function GitReferenceTree({
// shifts the colors of the repositories that remain visible.
const repositoryColors = useRepositoryColors(repositoryPaths);
- const repositoryGroups = useMemo(() => {
+ const plannedGroups = useMemo[]>(() => {
// Remote symbolic HEAD aliases are hidden so `origin/HEAD` does not appear
// as a selectable branch next to the remote's real branches.
const sources = isMultiRepository
@@ -889,24 +890,12 @@ export function GitReferenceTree({
showMyBranchesOnly,
selectedReference?.fullName,
);
- const trees = new Map(
- SECTION_KEYS.map(({ kind }) => [
- kind,
- buildGitReferenceTree(
- visibleReferences,
- kind,
- kind === "local" ? markedLocalReferenceFullNames : undefined,
- isMultiRepository ? `${repositoryKey}:` : undefined,
- ),
- ]),
- );
return {
repositoryPath,
repositoryKey,
references: repositoryReferences,
markedLocalReferenceFullNames,
visibleReferences,
- trees,
currentReference:
repositoryReferences.find((reference) => reference.isCurrent) ?? null,
remoteReferences: repositoryReferences.filter(
@@ -930,6 +919,34 @@ export function GitReferenceTree({
selectedReference?.fullName,
showMyBranchesOnly,
]);
+ // Core groups and orders every tree; the view keeps expansion and rows.
+ const treeSources = useMemo(
+ () =>
+ plannedGroups.flatMap((group) =>
+ SECTION_KEYS.map(({ kind }) => ({
+ key: `${group.repositoryKey}\u0000${kind}`,
+ references: group.visibleReferences,
+ kind,
+ marked: kind === "local" ? group.markedLocalReferenceFullNames : undefined,
+ idPrefix: isMultiRepository ? `${group.repositoryKey}:` : undefined,
+ })),
+ ),
+ [isMultiRepository, plannedGroups],
+ );
+ const plannedTrees = useGitReferenceTrees(treeSources);
+ const repositoryGroups = useMemo(
+ () =>
+ plannedGroups.map((group) => ({
+ ...group,
+ trees: new Map(
+ SECTION_KEYS.map(({ kind }) => [
+ kind,
+ plannedTrees?.get(`${group.repositoryKey}\u0000${kind}`) ?? [],
+ ]),
+ ),
+ })),
+ [plannedGroups, plannedTrees],
+ );
const activeGroup =
repositoryGroups.find((group) => group.repositoryKey === activeRepositoryKey) ??
repositoryGroups[0];
diff --git a/windows/tauri/src/features/git/components/status/git-status-panel.tsx b/windows/tauri/src/features/git/components/status/git-status-panel.tsx
index a2bcb39f4..c20b85687 100644
--- a/windows/tauri/src/features/git/components/status/git-status-panel.tsx
+++ b/windows/tauri/src/features/git/components/status/git-status-panel.tsx
@@ -72,8 +72,11 @@ import {
countGitChangedFiles,
isBelowUntrackedDirectory,
isUntrackedDirectorySummary,
- withoutListedUntrackedContents,
} from "../../utils/git-untracked-directories";
+import {
+ normalizeGitChangeTargets,
+ resolveGitStatusDeletion,
+} from "../../api/git-change-targets-api";
import { setUntrackedDirectoryExpanded } from "../../services/git-untracked-directory-expansions";
import {
buildGitIgnorePaths,
@@ -82,7 +85,6 @@ import {
getGitFileRepositoryRelativePath,
resolveGitFileMutationPaths,
resolveGitFilesForStagedState,
- resolveGitStatusDeletionPaths,
resolveGitStatusContextSelection,
} from "../../utils/git-status-selection";
import { StashMessageModal } from "../stash/git-stash-modal";
@@ -791,7 +793,18 @@ const GitStatusPanel = ({
stagePendingPaths.size > 0
)
return;
- const selected = withoutListedUntrackedContents(entries.flatMap((entry) => entry.files));
+ let selected: GitFile[];
+ try {
+ selected = await normalizeGitChangeTargets(
+ entries.flatMap((entry) => entry.files),
+ repoPath,
+ );
+ } catch (error) {
+ toast.error(
+ t("git.operationError", { error: error instanceof Error ? error.message : String(error) }),
+ );
+ return;
+ }
if (
selected.length === 0 ||
selected.some((file) => file.canToggleStaging === false || !file.rawStatus)
@@ -950,7 +963,16 @@ const GitStatusPanel = ({
};
const handleDeleteEntries = async (entries: GitStatusSelectionEntry[]) => {
- const filePaths = resolveGitStatusDeletionPaths(entries);
+ let deletion: { paths: string[]; targets: GitFile[] };
+ try {
+ deletion = await resolveGitStatusDeletion(entries, repoPath);
+ } catch (error) {
+ toast.error(
+ t("git.operationError", { error: error instanceof Error ? error.message : String(error) }),
+ );
+ return;
+ }
+ const filePaths = deletion.paths;
if (filePaths.length === 0) return;
const singleFileEntry = entries.length === 1 && entries[0]?.kind === "file";
const confirmationMessage = singleFileEntry
@@ -973,9 +995,7 @@ const GitStatusPanel = ({
const result = await deleteGitStatusPaths(
filePaths,
async (filePath) => {
- const file = entries
- .flatMap((entry) => entry.files)
- .find((candidate) => candidate.path === filePath);
+ const file = deletion.targets.find((candidate) => candidate.path === filePath);
const fileRepoPath = file ? getGitFileRepositoryPath(file, repoPath) : repoPath;
// A summarized directory is reported as `dir/`; the file tree names it `dir`.
const relativePath = (file ? getGitFileRepositoryRelativePath(file) : filePath).replace(
@@ -987,11 +1007,7 @@ const GitStatusPanel = ({
},
async () => onRefresh?.(),
async () => {
- const selectedPaths = new Set(filePaths);
- const selected = withoutListedUntrackedContents(
- entries.flatMap((entry) => entry.files),
- ).filter((file) => selectedPaths.has(file.path));
- for (const group of groupGitFilesByRepository(selected, repoPath)) {
+ for (const group of groupGitFilesByRepository(deletion.targets, repoPath)) {
await reconcileDeletedSelection(
group.repoPath,
group.files.map((file) => ({
@@ -1685,9 +1701,10 @@ const GitStatusPanel = ({
() => resolveGitFilesForStagedState(contextMenuFiles, true),
[contextMenuFiles],
);
- const contextMenuDeletionPaths = useMemo(
- () => resolveGitStatusDeletionPaths(contextMenuEntries),
- [contextMenuEntries],
+ // Every selected row that still exists is either a target or below one.
+ const contextMenuCanDelete = useMemo(
+ () => contextMenuFiles.some((file) => file.status !== "deleted"),
+ [contextMenuFiles],
);
const contextMenuTarget = contextMenuEntries.length === 1 ? contextMenuEntries[0] : null;
const contextMenuHasTrackedFiles = contextMenuFiles.some((file) => file.status !== "untracked");
@@ -2083,7 +2100,7 @@ const GitStatusPanel = ({
label: t("git.delete"),
icon: ,
disabled:
- contextMenuDeletionPaths.length === 0 ||
+ !contextMenuCanDelete ||
isLoading ||
stagePendingPaths.size > 0,
className: "text-destructive",
diff --git a/windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx b/windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx
index be928b3ca..5c54e7a66 100644
--- a/windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx
+++ b/windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx
@@ -70,6 +70,25 @@ beforeEach(() => {
staging = spyOn(statusApi, "setFilesStaged").mockResolvedValue(true);
const originalInvoke = core.invoke;
colorInvoke = spyOn(core, "invoke").mockImplementation(((command, args, options) => {
+ if (command === "git.normalizeChangeTargets") {
+ // Stands in for Core, whose own tests and shared fixture define the rule:
+ // drop rows listed below a selected untracked directory summary of the same repository.
+ const { targets } = args as {
+ targets: Array<{ root: string; path: string; untrackedDirectory?: boolean }>;
+ };
+ const indices = targets.flatMap((target, index) =>
+ targets.some(
+ (summary) =>
+ summary.untrackedDirectory &&
+ summary.root === target.root &&
+ summary.path !== target.path &&
+ target.path.startsWith(summary.path),
+ )
+ ? []
+ : [index],
+ );
+ return Promise.resolve({ indices });
+ }
if (command !== "git.repositoryColorAssignments") return originalInvoke(command, args, options);
const page = args as { offset: number; totalCount: number; repositoryIds: string[] };
return Promise.resolve({ assignments: page.repositoryIds.map((id, index) => ({ id, slot: (page.offset + index) % 8 })), fallbackSlot: 0, showColors: page.totalCount > 1 });
diff --git a/windows/tauri/src/features/git/services/git-execution-journal.test.ts b/windows/tauri/src/features/git/services/git-execution-journal.test.ts
index fafa90763..edda2cfdc 100644
--- a/windows/tauri/src/features/git/services/git-execution-journal.test.ts
+++ b/windows/tauri/src/features/git/services/git-execution-journal.test.ts
@@ -5,6 +5,15 @@ import type { GitExecutionEvent } from "@/platform/git-execution-events";
import { describe, expect, test } from "bun:test";
import { GitExecutionJournal, gitConsoleCommand, gitConsoleConfiguration, gitConsoleTimestamp } from "./git-execution-journal";
+const retention = fixture.events[0].retention as NonNullable;
+/** A journal that has seen Core's budget, as every real session has. */
+function journalWithRetention(now: () => number = () => 0) {
+ const journal = new GitExecutionJournal(now);
+ journal.receive({ operationId: "budget", type: "requestStarted", retention });
+ journal.receive({ operationId: "budget", type: "requestFinished", unconfirmedInvocations: [], failureRecord: false });
+ return journal;
+}
+
describe("Git execution journal", () => {
test("shared lifecycle keeps preflight silent and suppresses cleared requests", () => {
for (const sample of lifecycle.cases) {
@@ -25,12 +34,12 @@ describe("Git execution journal", () => {
}
});
test("silent queries never consume or evict the full visible command history", () => {
- const journal = new GitExecutionJournal(() => 0);
+ const journal = journalWithRetention();
for (let index = 0; index < 200; index++) {
const operationId = `command-${index}`;
journal.receive({ operationId, type: "started", invocationId: 1, workingDirectory: "C:/repo", arguments: ["commit"] });
- journal.receive({ operationId, type: "finished", invocationId: 1, exitCode: 0 });
- journal.receive({ operationId, type: "requestFinished" });
+ journal.receive({ operationId, type: "finished", invocationId: 1, exitCode: 0, state: "completed" });
+ journal.receive({ operationId, type: "requestFinished", unconfirmedInvocations: [], failureRecord: false });
}
const retained = journal.records;
const originalIds = retained.map((record) => record.id);
@@ -45,11 +54,11 @@ describe("Git execution journal", () => {
expect(journal.active.size).toBe(0);
});
test("failed preflight retains its reason and directory without claiming Git started", () => {
- const journal = new GitExecutionJournal(() => 0);
+ const journal = journalWithRetention();
for (let index = 0; index < 201; index++) {
const operationId = `failed-${index}`;
journal.receive({ operationId, type: "requestStarted", workingDirectory: "C:/denied", action: "git_fetch" });
- journal.receive({ operationId, type: "requestFinished", error: { message: "Could not open repository", details: "Permission denied" } });
+ journal.receive({ operationId, type: "requestFinished", failureRecord: true, error: { message: "Could not open repository", details: "Permission denied" } });
}
expect(journal.records).toHaveLength(200);
expect(journal.records[199]).toMatchObject({ root: "C:/denied", action: "git_fetch", arguments: [], state: "unconfirmed",
@@ -59,14 +68,14 @@ describe("Git execution journal", () => {
expect(journal.active.size).toBe(0);
});
test("all operation sources share history before a console subscribes", () => {
- const journal = new GitExecutionJournal(() => 0);
+ const journal = journalWithRetention();
for (const command of ["add", "commit", "checkout", "push", "stash", "rebase", "worktree"]) {
journal.receive({ operationId: command, type: "requestStarted", workingDirectory: "C:/repo" });
journal.receive({ operationId: command, type: "started", invocationId: 1, workingDirectory: "C:/repo", arguments: [command] });
}
for (const command of ["push", "add", "commit", "checkout", "stash", "rebase", "worktree"]) {
journal.receive({ operationId: command, type: "output", invocationId: 1, stream: "stdout", text: command });
- journal.receive({ operationId: command, type: "finished", invocationId: 1, exitCode: 0 });
+ journal.receive({ operationId: command, type: "finished", invocationId: 1, exitCode: 0, state: "completed" });
journal.receive({ operationId: command, type: "requestFinished" });
}
expect(journal.records.map((record) => record.arguments[0])).toEqual(["add", "commit", "checkout", "push", "stash", "rebase", "worktree"]);
@@ -104,7 +113,7 @@ describe("Git execution journal", () => {
expect(journal.records[0].output).toBe("Receiving: 100%\nreference updated\n");
});
test("empty output rows stay bounded without losing recent interleaved streams", () => {
- const journal = new GitExecutionJournal(() => 0);
+ const journal = journalWithRetention();
journal.receive({ operationId: "fetch", type: "started", invocationId: 1, workingDirectory: "C:/repo", arguments: ["fetch"] });
for (let index = 0; index < 2_100; index++) {
journal.receive({ operationId: "fetch", type: "output", invocationId: 1, stream: "stdout", text: "" });
@@ -138,12 +147,12 @@ describe("Git execution journal", () => {
journal.receive({ operationId: "new", type: "requestFinished" });
});
test("progress on stderr does not make a successful command fail", () => {
- const journal = new GitExecutionJournal();
+ const journal = journalWithRetention();
journal.receive({ operationId: "fetch", type: "started", invocationId: 1, workingDirectory: "C:/repo", arguments: ["fetch"] });
journal.receive({ operationId: "fetch", type: "output", invocationId: 1, stream: "stderr", text: "Receiving: 50%", progress: true });
expect(journal.records[0].progress).toBe("Receiving: 50%");
expect(journal.records[0].output).toBe("");
- journal.receive({ operationId: "fetch", type: "finished", invocationId: 1, exitCode: 0, durationMilliseconds: 42 });
+ journal.receive({ operationId: "fetch", type: "finished", invocationId: 1, exitCode: 0, state: "completed", durationMilliseconds: 42 });
expect(journal.records[0].state).toBe("completed");
expect(journal.records[0].error).toBeUndefined();
expect(journal.records[0].durationMilliseconds).toBe(42);
@@ -163,19 +172,19 @@ describe("Git execution journal", () => {
expect(journal.records).toEqual([]);
expect(journal.active.size).toBe(0);
});
- test("output is bounded and preview formatting does not expose URL credentials", () => {
- const journal = new GitExecutionJournal();
+ test("output is bounded by Core's budget and preview formatting does not expose URL credentials", () => {
+ const journal = journalWithRetention();
journal.receive({ operationId: "fetch", type: "started", invocationId: 1, workingDirectory: "C:/repo", arguments: ["fetch"] });
journal.receive({ operationId: "fetch", type: "output", invocationId: 1, text: "x".repeat(100_000) });
expect(journal.records[0].truncated).toBe(true);
- expect(journal.records[0].output.length).toBeLessThanOrEqual(65_536);
+ expect(journal.records[0].output.length).toBeLessThanOrEqual(retention.maxRecordOutputCharacters);
expect(gitConsoleCommand(["fetch", "https://user:fake@example.invalid/repo"])).not.toContain("fake");
});
});
test("discarded execution history is marked separately from foldable retained output", () => {
- const journal = new GitExecutionJournal(() => 0);
+ const journal = journalWithRetention();
for (let index = 0; index < 205; index += 1) {
journal.receive({ operationId: `query-${index}`, type: "started", invocationId: 1, workingDirectory: "C:/repo", arguments: ["status"] });
}
diff --git a/windows/tauri/src/features/git/services/git-execution-journal.ts b/windows/tauri/src/features/git/services/git-execution-journal.ts
index 173616e5d..49b58ec77 100644
--- a/windows/tauri/src/features/git/services/git-execution-journal.ts
+++ b/windows/tauri/src/features/git/services/git-execution-journal.ts
@@ -1,4 +1,4 @@
-import type { GitExecutionEvent } from "@/platform/git-execution-events";
+import type { GitExecutionEvent, GitJournalRetention } from "@/platform/git-execution-events";
export interface GitConsoleRecord {
id: string;
@@ -26,12 +26,12 @@ export interface GitConsoleRecord {
error?: string;
}
-const MAX_RECORDS = 200;
-const MAX_OUTPUT = 65_536;
-const MAX_OUTPUT_LINES = 2_000;
-const MAX_TOTAL_OUTPUT = 1_048_576;
-
-/** Receives complete sanitized native lines. State is intentionally memory-only. */
+/**
+ * Stores the ordered native events Core emits. State is intentionally memory-only.
+ *
+ * Core owns record states, redaction, and the retention budget (carried by
+ * `requestStarted`); the journal only stores, trims to that budget, and exposes records.
+ */
export class GitExecutionJournal {
constructor(private readonly now: () => number = Date.now) {}
records: GitConsoleRecord[] = [];
@@ -41,6 +41,7 @@ export class GitExecutionJournal {
private hidden = new Set();
private pending = new Map();
private sequence = 0;
+ private retention: GitJournalRetention | null = null;
clear(root?: string) {
this.records = this.records.filter((record) => root != null && !sameGitRoot(record.root, root));
@@ -53,6 +54,7 @@ export class GitExecutionJournal {
if (event.type === "started") this.sequence += 1;
if (event.type === "authentication") { this.authentication.push(event); return; }
if (event.type === "requestStarted") {
+ if (event.retention) this.retention = event.retention;
this.active.set(operationId, event.workingDirectory ?? "");
this.pending.set(operationId, { root: event.workingDirectory ?? "", action: event.action ?? "Git" });
return;
@@ -66,15 +68,15 @@ export class GitExecutionJournal {
const records = this.records.filter((record) => record.operationId === operationId);
let last = records[records.length - 1];
// A failed preflight needs its reason, but is never presented as an executed command.
- // A request cancelled before any command ran (a newer request superseded it) is not a failure.
- if (!last && event.error && event.error.code !== "cancelled") {
+ if (!last && event.failureRecord && event.error) {
last = { id: operationId, operationId, timestamp: this.now(), root: pending?.root ?? event.workingDirectory ?? "",
action: pending?.action ?? event.action ?? "Git", arguments: [], state: "unconfirmed", output: "", lines: [], truncated: false };
this.records.push(last);
}
- if (last && event.error) last.error = redactConsoleText([event.error.message, event.error.details].filter(Boolean).join("\n"));
+ if (last && event.error) last.error = [event.error.message, event.error.details].filter(Boolean).join("\n");
+ const unconfirmed = new Set(event.unconfirmedInvocations ?? []);
for (const record of records) {
- if (record.state === "running" || record.state === "queued") record.state = "unconfirmed";
+ if (unconfirmed.has(Number(record.id.slice(operationId.length + 1)))) record.state = "unconfirmed";
}
this.trimHistory();
return;
@@ -82,7 +84,7 @@ export class GitExecutionJournal {
if (this.hidden.has(operationId)) return;
if (event.type === "remoteResult") {
const record = [...this.records].reverse().find((record) => record.operationId === operationId && (event.invocationId == null || record.id === `${operationId}:${event.invocationId}`));
- if (record) { record.remoteResult = event; if (event.error) record.error = redactConsoleText(event.error.message); }
+ if (record) { record.remoteResult = event; if (event.error) record.error = event.error.message; }
return;
}
const id = `${operationId}:${event.invocationId}`;
@@ -96,34 +98,39 @@ export class GitExecutionJournal {
const record = [...this.records].reverse().find((record) => record.id === id);
if (record && event.type === "output") {
record.phase = event.progressDetails ?? record.phase;
- if (event.progress) record.progress = redactConsoleText(event.text ?? "");
+ if (event.progress) record.progress = event.text ?? "";
else {
- const text = redactConsoleText(event.text ?? "");
- record.lines.push({ stream: event.stream === "stderr" ? "stderr" : "stdout", text: text.slice(-(MAX_OUTPUT - 1)) });
- let characters = record.output.length + record.lines[record.lines.length - 1].text.length + 1;
- while (record.lines.length > 1 && (characters > MAX_OUTPUT || record.lines.length > MAX_OUTPUT_LINES)) {
- characters -= record.lines.shift()!.text.length + 1;
- record.truncated = true;
+ const limits = this.retention;
+ const text = limits ? (event.text ?? "").slice(-(limits.maxRecordOutputCharacters - 1)) : (event.text ?? "");
+ record.lines.push({ stream: event.stream === "stderr" ? "stderr" : "stdout", text });
+ if (limits) {
+ let characters = record.output.length + text.length + 1;
+ while (record.lines.length > 1 && (characters > limits.maxRecordOutputCharacters || record.lines.length > limits.maxRecordOutputLines)) {
+ characters -= record.lines.shift()!.text.length + 1;
+ record.truncated = true;
+ }
+ record.truncated ||= (event.text ?? "").length >= limits.maxRecordOutputCharacters;
}
- record.truncated ||= text.length >= MAX_OUTPUT;
record.output = record.lines.map((line) => line.text + "\n").join("");
record.progress = undefined;
}
record.truncated ||= !!event.truncated;
}
if (record && event.type === "finished") {
- record.state = event.exitCode == null ? "unconfirmed" : "completed";
+ record.state = event.state ?? "unconfirmed";
record.exitCode = event.exitCode ?? undefined;
record.expectedExit = event.expectedExit ?? false;
record.durationMilliseconds = event.durationMilliseconds;
- record.error = event.error ? redactConsoleText([event.error.message, event.error.details].filter(Boolean).join("\n")) : undefined;
+ record.error = event.error ? [event.error.message, event.error.details].filter(Boolean).join("\n") : undefined;
record.progress = undefined;
}
this.trimHistory();
}
private trimHistory() {
- while (this.records.length > MAX_RECORDS || (this.records.length > 1 && this.records.reduce((sum, record) => sum + record.output.length, 0) > MAX_TOTAL_OUTPUT)) {
+ const limits = this.retention;
+ if (!limits) return;
+ while (this.records.length > limits.maxRecords || (this.records.length > 1 && this.records.reduce((sum, record) => sum + record.output.length, 0) > limits.maxTotalOutputCharacters)) {
this.historyTruncated = true;
this.records.shift();
}
diff --git a/windows/tauri/src/features/git/utils/git-rebase-plan.ts b/windows/tauri/src/features/git/utils/git-rebase-plan.ts
index 079b52f90..d43833439 100644
--- a/windows/tauri/src/features/git/utils/git-rebase-plan.ts
+++ b/windows/tauri/src/features/git/utils/git-rebase-plan.ts
@@ -1,42 +1,59 @@
+import { useEffect, useState } from "react";
+import { invoke } from "@/platform/tauri-core";
import type { GitHistoryRewriteCommit } from "../types/git-history-rewrite.types";
import type { GitRebaseStep } from "../types/git-rebase.types";
-/** Plan messages are explicit user edits; predicted squash text stays presentation-only. */
-export function prepareGitRebasePlan(
- steps: GitRebaseStep[],
- commits: GitHistoryRewriteCommit[],
-): { steps: GitRebaseStep[]; messages: Map } {
- const originals = new Map(commits.map((commit) => [commit.hash, commit.message]));
- const messages = new Map();
- let combined = "";
- const prepared = steps.map((step): GitRebaseStep => {
- const original = originals.get(step.hash) ?? "";
- let displayed = original;
- switch (step.action) {
- case "pick":
- case "edit":
- combined = original;
- break;
- case "reword":
- combined = displayed = step.message ?? original;
- break;
- case "squash":
- combined = displayed = step.message ?? `${combined.replace(/\n+$/, "")}\n\n${original}`;
- break;
- case "fixup":
- case "drop":
- break;
- }
- messages.set(step.hash, displayed);
- return {
- hash: step.hash,
- action: step.action,
- ...(step.action === "reword"
- ? { message: displayed }
- : step.action === "squash" && step.message !== undefined
- ? { message: step.message }
- : {}),
- };
+/** What the plan editor presents and submits for one draft (`git.rebasePlanPreview`). */
+export interface GitRebasePlanPreview {
+ /** First problem the user must fix; `null` when the plan can start. */
+ issue: {
+ code: "missingPrecedingCommit" | "emptyRewordMessage" | "emptySquashMessage" | "invalidMessage";
+ hash: string | null;
+ } | null;
+ /** Draft rows with the message the editor shows for each. */
+ steps: Array<{ hash: string; action: GitRebaseStep["action"]; message: string }>;
+ /** Exact steps for `git.rebaseStart`. */
+ startSteps: GitRebaseStep[];
+ outputCommitCount: number;
+}
+
+/**
+ * Validates a rebase draft and derives its messages in Core, with the rules the
+ * start command applies. Plan messages are explicit user edits; predicted squash
+ * text stays presentation-only.
+ */
+export function loadGitRebasePlanPreview(
+ steps: readonly GitRebaseStep[],
+ commits: readonly GitHistoryRewriteCommit[],
+): Promise {
+ return invoke("git.rebasePlanPreview", {
+ commits: commits.map(({ hash, message }) => ({ hash, message })),
+ steps,
});
- return { steps: prepared, messages };
+}
+
+/** Core's preview for the current draft; `null` while it loads, after a rejected draft, or without commits. */
+export function useGitRebasePlanPreview(
+ steps: readonly GitRebaseStep[],
+ commits: readonly GitHistoryRewriteCommit[] | undefined,
+): GitRebasePlanPreview | null {
+ const [snapshot, setSnapshot] = useState<{
+ steps: readonly GitRebaseStep[];
+ plan: GitRebasePlanPreview;
+ } | null>(null);
+ useEffect(() => {
+ if (!commits || commits.length === 0) return;
+ let current = true;
+ loadGitRebasePlanPreview(steps, commits)
+ .then((plan) => {
+ if (current) setSnapshot({ steps, plan });
+ })
+ .catch((error) => {
+ if (current) console.error("Failed to preview the rebase plan:", error);
+ });
+ return () => {
+ current = false;
+ };
+ }, [steps, commits]);
+ return snapshot?.steps === steps ? snapshot.plan : null;
}
diff --git a/windows/tauri/src/features/git/utils/git-reference-tree.test.ts b/windows/tauri/src/features/git/utils/git-reference-tree.test.ts
index ae7bc3bc4..e8f027b2b 100644
--- a/windows/tauri/src/features/git/utils/git-reference-tree.test.ts
+++ b/windows/tauri/src/features/git/utils/git-reference-tree.test.ts
@@ -1,13 +1,36 @@
-import { describe, expect, test } from "bun:test";
+import { describe, expect, mock, test } from "bun:test";
import type { GitReference } from "../types/git.types";
-import {
- buildGitReferenceTree,
+import * as tauriCoreModule from "@/platform/tauri-core";
+
+const invoke = mock(async (_command: string, args: { references: { name: string }[] }) => {
+ // Stands in for Core: shared/fixtures/git/reference-tree-v1.json owns the grouping rules.
+ const [first] = args.references;
+ return {
+ nodes: [
+ {
+ name: "origin",
+ path: "origin",
+ reference: null,
+ children: args.references.map((item, index) => ({
+ name: item.name.slice("origin/".length),
+ path: item.name,
+ reference: index,
+ children: [],
+ })),
+ },
+ ].filter(() => first),
+ };
+});
+mock.module("@/platform/tauri-core", () => ({ ...tauriCoreModule, invoke }));
+
+const {
+ loadGitReferenceTree,
collectGitReferenceGroupIds,
countGitReferencesByKind,
filterGitLogReferences,
filterSelectableGitReferences,
isRemoteSymbolicHeadReference,
-} from "./git-reference-tree";
+} = await import("./git-reference-tree");
const reference = (shortName: string): GitReference => ({
fullName: `refs/remotes/${shortName}`,
@@ -18,20 +41,20 @@ const reference = (shortName: string): GitReference => ({
});
describe("Git reference tree", () => {
- test("groups slash-delimited references without losing leaf references", () => {
- const tree = buildGitReferenceTree(
- [reference("origin/main"), reference("origin/feature/orders")],
+ test("attaches loaded references and row identities to the Core plan", async () => {
+ const tree = await loadGitReferenceTree(
+ [reference("origin/main"), reference("origin/orders")],
"remote",
+ new Set(),
+ "A:",
);
- expect(tree).toHaveLength(1);
- expect(tree[0].name).toBe("origin");
- expect(tree[0].children.map((node) => node.name)).toEqual(["feature", "main"]);
- expect(tree[0].children[0].children[0].reference?.shortName).toBe("origin/feature/orders");
- expect(collectGitReferenceGroupIds(tree)).toEqual([
- "remote:origin",
- "remote:origin/feature",
- ]);
+ expect(invoke).toHaveBeenCalledWith("git.referenceTree", {
+ references: [{ name: "origin/main" }, { name: "origin/orders" }],
+ });
+ expect(tree[0].id).toBe("A:remote:origin");
+ expect(tree[0].children[1].reference?.shortName).toBe("origin/orders");
+ expect(collectGitReferenceGroupIds(tree)).toEqual(["A:remote:origin"]);
});
test("counts references instead of top-level namespace nodes", () => {
@@ -47,28 +70,27 @@ describe("Git reference tree", () => {
},
];
- expect(buildGitReferenceTree(references, "remote")).toHaveLength(1);
expect(countGitReferencesByKind(references, "remote")).toBe(2);
expect(countGitReferencesByKind(references, "local")).toBe(1);
});
- test("sorts marked local branches before groups and ordinary branches", () => {
- const localReference = (shortName: string): GitReference => ({
+ test("sends marked local branches as prioritized references", async () => {
+ const local = (shortName: string): GitReference => ({
fullName: `refs/heads/${shortName}`,
shortName,
kind: "local",
peelsToCommit: true,
isCurrent: false,
});
- const references = [
- localReference("feature/orders"),
- localReference("develop"),
- localReference("main"),
- ];
-
- const tree = buildGitReferenceTree(references, "local", new Set(["refs/heads/main"]));
+ await loadGitReferenceTree(
+ [local("develop"), local("main")],
+ "local",
+ new Set(["refs/heads/main"]),
+ );
- expect(tree.map((node) => node.name)).toEqual(["main", "feature", "develop"]);
+ expect(invoke).toHaveBeenLastCalledWith("git.referenceTree", {
+ references: [{ name: "develop" }, { name: "main", prioritized: true }],
+ });
});
test("shows current, marked, and current-upstream references in my branches mode", () => {
diff --git a/windows/tauri/src/features/git/utils/git-reference-tree.ts b/windows/tauri/src/features/git/utils/git-reference-tree.ts
index 6e7fb01d3..c95a6b2ad 100644
--- a/windows/tauri/src/features/git/utils/git-reference-tree.ts
+++ b/windows/tauri/src/features/git/utils/git-reference-tree.ts
@@ -1,3 +1,5 @@
+import { useEffect, useState } from "react";
+import { invoke } from "@/platform/tauri-core";
import type { GitReference, GitReferenceKind } from "../types/git.types";
export interface GitReferenceTreeNode {
@@ -8,10 +10,6 @@ export interface GitReferenceTreeNode {
children: GitReferenceTreeNode[];
}
-interface MutableReferenceNode extends GitReferenceTreeNode {
- children: MutableReferenceNode[];
-}
-
export function countGitReferencesByKind(
references: GitReference[],
kind: GitReferenceKind,
@@ -67,58 +65,91 @@ export function collectGitReferenceGroupIds(nodes: GitReferenceTreeNode[]): stri
]);
}
-export function buildGitReferenceTree(
- references: GitReference[],
+/** One node of the tree `git.referenceTree` returns; `reference` indexes the request. */
+interface CoreReferenceTreeNode {
+ name: string;
+ path: string;
+ reference: number | null;
+ children: CoreReferenceTreeNode[];
+}
+
+/**
+ * Asks Core to group and order the references of one kind (`git.referenceTree`).
+ *
+ * Core owns the `/` grouping and the sibling order; this only attaches the
+ * loaded references and the view's row identities to the planned nodes.
+ */
+export async function loadGitReferenceTree(
+ references: readonly GitReference[],
kind: GitReferenceKind,
markedReferenceFullNames: ReadonlySet = new Set(),
idPrefix = "",
-): GitReferenceTreeNode[] {
- const roots: MutableReferenceNode[] = [];
-
- for (const reference of references.filter((item) => item.kind === kind)) {
- const parts = reference.shortName.split("/").filter(Boolean);
- let siblings = roots;
- let path = "";
-
- parts.forEach((part, index) => {
- path = path ? `${path}/${part}` : part;
- let node = siblings.find((candidate) => candidate.name === part);
- if (!node) {
- node = { id: `${idPrefix}${kind}:${path}`, name: part, path, children: [] };
- siblings.push(node);
- }
- if (index === parts.length - 1) node.reference = reference;
- siblings = node.children;
- });
- }
-
- const containsMarkedReference = (node: MutableReferenceNode): boolean =>
- Boolean(node.reference && markedReferenceFullNames.has(node.reference.fullName)) ||
- node.children.some(containsMarkedReference);
-
- const sortNodes = (nodes: MutableReferenceNode[]) => {
- nodes.sort((left, right) => {
- const leftIsMarked = Boolean(
- left.reference && markedReferenceFullNames.has(left.reference.fullName),
- );
- const rightIsMarked = Boolean(
- right.reference && markedReferenceFullNames.has(right.reference.fullName),
- );
- if (leftIsMarked !== rightIsMarked) return leftIsMarked ? -1 : 1;
+): Promise {
+ const ofKind = references.filter((item) => item.kind === kind);
+ if (ofKind.length === 0) return [];
+ const plan = await invoke<{ nodes: CoreReferenceTreeNode[] }>("git.referenceTree", {
+ references: ofKind.map((item) => ({
+ name: item.shortName,
+ ...(markedReferenceFullNames.has(item.fullName) ? { prioritized: true } : {}),
+ })),
+ });
+ const attach = (node: CoreReferenceTreeNode): GitReferenceTreeNode => {
+ const reference = node.reference === null ? undefined : ofKind[node.reference];
+ if (node.reference !== null && !reference) throw new Error("Invalid Git reference tree plan");
+ return {
+ id: `${idPrefix}${kind}:${node.path}`,
+ name: node.name,
+ path: node.path,
+ ...(reference ? { reference } : {}),
+ children: node.children.map(attach),
+ };
+ };
+ return plan.nodes.map(attach);
+}
- const leftContainsMarked = containsMarkedReference(left);
- const rightContainsMarked = containsMarkedReference(right);
- if (leftContainsMarked !== rightContainsMarked) return leftContainsMarked ? -1 : 1;
+export interface GitReferenceTreeSource {
+ key: string;
+ references: readonly GitReference[];
+ kind: GitReferenceKind;
+ marked?: ReadonlySet;
+ idPrefix?: string;
+}
- if (Boolean(left.children.length) !== Boolean(right.children.length)) {
- return left.children.length ? -1 : 1;
- }
- return left.name.localeCompare(right.name);
- });
- nodes.forEach((node) => sortNodes(node.children));
- };
- sortNodes(roots);
- return roots;
+/**
+ * Trees for several sources, planned by Core outside rendering. `null` until the
+ * latest sources resolve; stale results are discarded.
+ */
+export function useGitReferenceTrees(
+ sources: readonly GitReferenceTreeSource[],
+): ReadonlyMap | null {
+ const [snapshot, setSnapshot] = useState<{
+ sources: readonly GitReferenceTreeSource[];
+ trees: Map;
+ } | null>(null);
+ useEffect(() => {
+ let current = true;
+ void Promise.all(
+ sources.map(async (source) => [
+ source.key,
+ await loadGitReferenceTree(
+ source.references,
+ source.kind,
+ source.marked,
+ source.idPrefix,
+ ),
+ ] as const),
+ )
+ .then((entries) => {
+ if (current) setSnapshot({ sources, trees: new Map(entries) });
+ })
+ .catch((error) => {
+ if (current) console.error("Failed to plan Git reference trees:", error);
+ });
+ return () => {
+ current = false;
+ };
+ }, [sources]);
+ return snapshot?.sources === sources ? snapshot.trees : null;
}
/**
diff --git a/windows/tauri/src/features/git/utils/git-status-selection.test.ts b/windows/tauri/src/features/git/utils/git-status-selection.test.ts
index 367aefc5b..0859ce140 100644
--- a/windows/tauri/src/features/git/utils/git-status-selection.test.ts
+++ b/windows/tauri/src/features/git/utils/git-status-selection.test.ts
@@ -5,7 +5,6 @@ import {
resolveGitFileMutationPaths,
refreshedCommitSelectionMatches,
resolveGitFilesForStagedState,
- resolveGitStatusDeletionPaths,
resolveGitStatusContextSelection,
updateGitStatusSelection,
} from "./git-status-selection";
@@ -157,21 +156,4 @@ describe("Git status row selection", () => {
]),
).toEqual(["cache/nested/", "generated/", "local.env"]);
});
-
- test("deletes only source-control leaf files represented by selected folders", () => {
- expect(
- resolveGitStatusDeletionPaths([
- {
- files: [
- { path: "module/changed.ts", status: "modified" },
- { path: "module/new.ts", status: "untracked" },
- { path: "module/already-gone.ts", status: "deleted" },
- ],
- },
- {
- files: [{ path: "module/changed.ts", status: "modified" }],
- },
- ]),
- ).toEqual(["module/changed.ts", "module/new.ts"]);
- });
});
diff --git a/windows/tauri/src/features/git/utils/git-status-selection.ts b/windows/tauri/src/features/git/utils/git-status-selection.ts
index cce60077b..fcc457466 100644
--- a/windows/tauri/src/features/git/utils/git-status-selection.ts
+++ b/windows/tauri/src/features/git/utils/git-status-selection.ts
@@ -1,6 +1,5 @@
import { normalizeRepositoryPath } from "../api/git-repository-path";
import type { GitFile } from "../types/git.types";
-import { withoutListedUntrackedContents } from "./git-untracked-directories";
export function getGitFileRepositoryPath(file: GitFile, fallbackRepoPath?: string): string | null {
return file.repositoryPath ?? fallbackRepoPath ?? null;
@@ -97,18 +96,6 @@ export function buildGitIgnorePaths(
);
}
-export function resolveGitStatusDeletionPaths(
- entries: ReadonlyArray<{
- files: ReadonlyArray<{ path: string; status: string; untrackedFileCount?: number }>;
- }>,
-): string[] {
- // Deleting a summarized directory removes the contents listed below it.
- const files = withoutListedUntrackedContents(entries.flatMap((entry) => entry.files));
- return [
- ...new Set(files.filter((file) => file.status !== "deleted").map((file) => file.path)),
- ].sort((left, right) => left.localeCompare(right));
-}
-
/** Check the refreshed presentation before moving focus to the commit editor. */
export function refreshedCommitSelectionMatches(
selected: readonly GitFile[],
diff --git a/windows/tauri/src/features/git/utils/git-untracked-directories.test.ts b/windows/tauri/src/features/git/utils/git-untracked-directories.test.ts
index fd87e6b6b..d0f438e68 100644
--- a/windows/tauri/src/features/git/utils/git-untracked-directories.test.ts
+++ b/windows/tauri/src/features/git/utils/git-untracked-directories.test.ts
@@ -13,11 +13,9 @@ import {
type LocalChangelists,
} from "./git-changelists";
import { isGitGutterFileUntracked } from "./git-gutter-changes";
-import { resolveGitStatusDeletionPaths } from "./git-status-selection";
import {
countGitChangedFiles,
findContainingUntrackedDirectory,
- withoutListedUntrackedContents,
} from "./git-untracked-directories";
// Regression coverage for #1385: a large untracked directory is one `dir/`
@@ -55,17 +53,6 @@ describe("untracked directory summaries", () => {
expect(countUniqueGitChanges(undefined)).toBe(0);
});
- test("drop listed contents covered by a summary in the same set", () => {
- expect(withoutListedUntrackedContents(expanded).map((file) => file.path)).toEqual([
- "src/App.tsx",
- "target/",
- ]);
- // A sibling sharing the name prefix is not below the directory.
- expect(
- withoutListedUntrackedContents([untracked("target/", 200), untracked("targets.txt")]),
- ).toHaveLength(2);
- });
-
test("find the summary containing a repository-relative path in its own repository", () => {
const files = [untracked("target/", 200, "A"), untracked("target/", 300, "B")];
expect(findContainingUntrackedDirectory(files, "C:/workspace/B", "target/debug/app")).toBe(
@@ -100,13 +87,6 @@ describe("untracked directory summaries", () => {
expect(isGitGutterFileUntracked(files, "C:/workspace/B", "target/debug/app")).toBe(false);
expect(isGitGutterFileUntracked(files, "C:/workspace/A", "src/main.rs")).toBe(false);
});
-
- test("delete a summary once instead of again for each listed file", () => {
- expect(resolveGitStatusDeletionPaths([{ files: expanded }])).toEqual([
- "src/App.tsx",
- "target/",
- ]);
- });
});
test("directory rows consume the Core projection and unknown membership fails closed", () => {
diff --git a/windows/tauri/src/features/git/utils/git-untracked-directories.ts b/windows/tauri/src/features/git/utils/git-untracked-directories.ts
index 3ea88356f..731b77464 100644
--- a/windows/tauri/src/features/git/utils/git-untracked-directories.ts
+++ b/windows/tauri/src/features/git/utils/git-untracked-directories.ts
@@ -30,12 +30,11 @@ export function isBelowUntrackedDirectory(directory: CountedEntry, file: Counted
}
/**
- * Drops entries listed below a summary that is in the same set.
- *
- * Acting on the summary already acts on them; acting again would fail once the
- * summary's files are gone, and counting them again would double the total.
+ * Entries a change count includes: a listed file below a summary in the same
+ * set is already counted by the summary. Operation targets are normalized by
+ * Core instead (`normalizeGitChangeTargets`).
*/
-export function withoutListedUntrackedContents(files: readonly T[]): T[] {
+function withoutListedUntrackedContents(files: readonly T[]): T[] {
const summaries = files.filter(isUntrackedDirectorySummary);
if (summaries.length === 0) return [...files];
return files.filter(
diff --git a/windows/tauri/src/platform/git-execution-events.ts b/windows/tauri/src/platform/git-execution-events.ts
index 3ad08aeb3..36bef53f7 100644
--- a/windows/tauri/src/platform/git-execution-events.ts
+++ b/windows/tauri/src/platform/git-execution-events.ts
@@ -1,5 +1,12 @@
export type GitExecutionSource = "user" | "background" | "unknown";
+export interface GitJournalRetention {
+ maxRecords: number;
+ maxRecordOutputCharacters: number;
+ maxRecordOutputLines: number;
+ maxTotalOutputCharacters: number;
+}
+
/** Native diagnostics only; command input and environment are never included. */
export interface GitExecutionEvent {
operationId: string;
@@ -22,6 +29,14 @@ export interface GitExecutionEvent {
displayArguments?: string[];
globalArguments?: string[];
progressDetails?: { stage: string; percent?: number | null; completed?: number | null; total?: number | null };
+ /** `requestStarted`: the console journal budget Core defines for every host. */
+ retention?: GitJournalRetention;
+ /** `finished`: the record's final state, decided by Core. */
+ state?: "completed" | "unconfirmed";
+ /** `requestFinished`: records of this request whose exit was never observed. */
+ unconfirmedInvocations?: number[];
+ /** `requestFinished`: the failure needs its own record because no Git process started. */
+ failureRecord?: boolean;
requestId?: string;
prompt?: string;
secret?: boolean;