Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion Sources/CodecCore/GitRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}

Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion Sources/CodecCore/PromptConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]/}",
Expand Down
14 changes: 14 additions & 0 deletions Tests/CodecCoreTests/GitRepositoryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
27 changes: 27 additions & 0 deletions Tests/CodecCoreTests/ShellIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion docs/files-and-editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading