From ac88c3c3e5cfe864396e5a03f7d3488636258477 Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Tue, 3 Nov 2026 21:23:27 +0100 Subject: [PATCH] Read git without locking the repository The file tree runs `git status` whenever the repository changes, and `git status` rewrites the index to refresh its file stats, holding .git/index.lock while it does. Those changes are usually another app committing or staging, so that app (an IDE, git in another terminal) could fail because the index was locked. Codec's own git now runs with GIT_OPTIONAL_LOCKS=0, so reading the status never writes. The prompt's git changes segment had the same problem at every prompt: `git diff` refreshes the index even with optional locks off. It now uses `git diff-index`, which prints the same stats without writing. Fixes #6. Co-Authored-By: Claude Opus 5.5 --- Sources/CodecCore/GitRepository.swift | 8 +++++- Sources/CodecCore/PromptConfiguration.swift | 4 ++- Tests/CodecCoreTests/GitRepositoryTests.swift | 14 ++++++++++ .../ShellIntegrationTests.swift | 27 +++++++++++++++++++ docs/files-and-editor.md | 2 +- 5 files changed, 52 insertions(+), 3 deletions(-) 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.