diff --git a/Sources/CodecCore/GitRepository.swift b/Sources/CodecCore/GitRepository.swift index 32219a9..52df1f1 100644 --- a/Sources/CodecCore/GitRepository.swift +++ b/Sources/CodecCore/GitRepository.swift @@ -40,7 +40,8 @@ public protocol GitRunning: Sendable { func run(_ arguments: [String], in directory: URL) async throws -> (status: Int32, output: String, errors: String) } -/// Runs `/usr/bin/git` as a subprocess, without a terminal and without prompting. +/// Runs `/usr/bin/git` as a subprocess, without a terminal, without prompting, and without taking +/// locks it doesn't need. public struct ProcessGitRunner: GitRunning { public init() {} @@ -54,6 +55,11 @@ public struct ProcessGitRunner: GitRunning { // Never wait for a password or an editor that nobody can see. environment["GIT_TERMINAL_PROMPT"] = "0" environment["GIT_EDITOR"] = "true" + // `git status` otherwise rewrites the index to refresh its file stats, holding + // .git/index.lock while it does. The file tree runs it whenever the repository changes, + // which is just when another app (an IDE, git in another terminal) is committing or + // staging, and that app then fails because the index is locked. + environment["GIT_OPTIONAL_LOCKS"] = "0" process.environment = environment let output = Pipe(), errors = Pipe() process.standardOutput = output diff --git a/Sources/CodecCore/PromptConfiguration.swift b/Sources/CodecCore/PromptConfiguration.swift index d63b762..8b2504a 100644 --- a/Sources/CodecCore/PromptConfiguration.swift +++ b/Sources/CodecCore/PromptConfiguration.swift @@ -672,8 +672,10 @@ extension PromptConfiguration { return code case .gitDiffStats: // "3 files changed, 10 insertions(+), 2 deletions(-)" → "3 • +10 -2". Only digits reach the prompt. + // diff-index, not diff: `git diff` rewrites the index to refresh its file stats, even with + // GIT_OPTIONAL_LOCKS=0, and would lock it at every prompt against other apps using git. return [ - "value=$(command git diff --shortstat HEAD 2>/dev/null)", + "value=$(command git diff-index --shortstat HEAD -- 2>/dev/null)", "if [[ -n $value ]]; then", " local files=${value%% file*} insertions=0 deletions=0", " files=${files//[^0-9]/}", diff --git a/Tests/CodecCoreTests/GitRepositoryTests.swift b/Tests/CodecCoreTests/GitRepositoryTests.swift index b572392..2b6d4a2 100644 --- a/Tests/CodecCoreTests/GitRepositoryTests.swift +++ b/Tests/CodecCoreTests/GitRepositoryTests.swift @@ -101,6 +101,20 @@ struct GitRepositoryTests { await #expect(throws: GitError.self) { try await repository.createBranch("has space") } } + /// The file tree reads the status whenever the repository changes, which is when another app is + /// likely committing or staging: it must never hold the index's lock against that app. + @Test func readsTheStatusWithoutRewritingTheIndex() async throws { + let temp = try TemporaryDirectory() + let clone = try makeClone(in: temp) + // The same contents with another date: git would refresh the index to record it. + try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -3600)], + ofItemAtPath: clone.appendingPathComponent("file.txt").path) + let index = clone.appendingPathComponent(".git/index") + let before = try Data(contentsOf: index) + #expect(try await GitRepository(directory: clone).status().isEmpty) + #expect(try Data(contentsOf: index) == before) + } + @Test func failsCleanlyOutsideARepository() async throws { let temp = try TemporaryDirectory() let repository = GitRepository(directory: try temp.directory("plain")) diff --git a/Tests/CodecCoreTests/ShellIntegrationTests.swift b/Tests/CodecCoreTests/ShellIntegrationTests.swift index 56733bf..19258c5 100644 --- a/Tests/CodecCoreTests/ShellIntegrationTests.swift +++ b/Tests/CodecCoreTests/ShellIntegrationTests.swift @@ -353,6 +353,33 @@ struct ZshPromptTests { #expect(rendered.contains("%D{%H:%M}")) } + /// The prompt runs git before every command, so it must never lock the index against another app. + @Test func readsGitWithoutRewritingTheIndex() async throws { + let setup = try TemporaryDirectory() + let repo = try repository(in: setup, branch: "main") + let file = repo.appendingPathComponent("file.txt") + try "one\n".write(to: file, atomically: true, encoding: .utf8) + try git(["add", "."], in: repo) + try git(["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "init"], in: repo) + // The same contents with another date: `git diff` would refresh the index to record it. + try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -3600)], ofItemAtPath: file.path) + let index = repo.appendingPathComponent(".git/index") + let before = try Data(contentsOf: index) + + var configuration = prompt + configuration.showsGitChanges = true + configuration.segments = [ + .init(.gitBranch, enabled: true, color: .magenta), + .init(.gitDiffStats, enabled: true, color: .green), + ] + let zsh = try ZshHarness(prompt: configuration, directory: repo.path) + #expect(await zsh.wait { zsh.payloads(133).contains { $0.hasPrefix("A") } }) + zsh.reportPrompt() + #expect(await zsh.wait { !zsh.reportedPrompts.isEmpty }) + #expect(zsh.reportedPrompts.last?.contains("main") == true) + #expect(try Data(contentsOf: index) == before) + } + /// The prompt zsh draws must match the Settings preview exactly. @Test(arguments: [true, false]) func drawsExactlyWhatThePreviewShows(connectingWords: Bool) async throws { diff --git a/docs/files-and-editor.md b/docs/files-and-editor.md index a39e889..da7e1c5 100644 --- a/docs/files-and-editor.md +++ b/docs/files-and-editor.md @@ -8,7 +8,7 @@ - It follows the pane as you `cd`, and opens down to the current directory. - It updates as files change on disk. -- Files are colored by git status (modified, added, untracked, ignored), and folders holding changes get a dot. +- Files are colored by git status (modified, added, untracked, ignored), and folders holding changes get a dot. Codec reads the status without locking the repository, so it never gets in the way of an IDE or git in another terminal. - Files and folders have Codec's own icons, drawn from the theme's palette: a page with a folded corner and a monogram (SW, TS, PY…) or a glyph per type (a `>_` for scripts, signal bars for audio, a key for `.env`…). Folders are colored by role (sources, tests, docs, assets, scripts), dimmed for build output and dependencies, with the current one in the accent color. - The eye button shows or hides hidden files.