native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Classify bare repos by structure, not directory name (#263)

Drop the `lastPathComponent.hasSuffix(".git")` gate around the
HEAD/objects/refs trio check in `Repository.isGitRepository(at:)` so
bare repos whose directory name does not end in `.git` (e.g.
`myrepo-bare/`) classify correctly instead of falling through to a
folder. Matches git's own `is_git_directory()` heuristic. Also require
`HEAD` to be a regular file so a directory with three child dirs named
HEAD/objects/refs is not a false positive.

authored by

Stefano Bertagno and committed by
GitHub
393a8557 6fff0218

+96 -36
+1 -1
AGENTS.md
··· 131 131 132 132 ## Folder (non-git) repositories 133 133 134 - - `Repository.isGitRepository` classifies each root at load time via `Repository.isGitRepository(at:)` (checks `.git` dir/file and the `.bare` / `.git` root-name conventions). Classification runs through the injected `GitClientDependency.isGitRepository` closure so tests can override it without touching the filesystem. 134 + - `Repository.isGitRepository` classifies each root at load time via `Repository.isGitRepository(at:)`, which approximates git's own `is_git_directory()` check: `.bare` / `.git` root-name shortcut, then `rootURL/.git` existence (worktree root, covers primary / linked / submodule / `--separate-git-dir` layouts), then the `HEAD` + `objects` + `refs` trio at the root — with `HEAD` required to be a regular file (git rejects a `HEAD` directory) — so any git dir is recognized regardless of naming, including bare clones whose directory name does not end in `.git`. Classification runs through the injected `GitClientDependency.isGitRepository` closure so tests can override it without touching the filesystem. 135 135 - A folder-kind repository has exactly one synthesized "main" `Worktree` with `id = "folder:" + path` (see `Repository.folderWorktreeID(for:)`), `workingDirectory == rootURL`. Selection and terminal binding reuse the standard `SidebarSelection.worktree(id)` machinery — nothing git-specific runs for folders. 136 136 - The sidebar renders each folder as its own `Section` with an empty header and a single selectable row. The context menu offers the same entries as a git worktree row, minus pin / archive / "Copy as Branch Name", plus "Folder Settings…" (the section has no header so there is no ellipsis menu). 137 137 - The Delete Script for a folder runs through the existing `.requestDeleteSidebarItems` → `.confirmDeleteSidebarItems` → `.deleteSidebarItemConfirmed` → `.deleteScriptCompleted` pipeline; the handlers branch inside so `gitClient.removeWorktree` is never called for a folder and the success path emits `.repositoryRemovalCompleted`, which the batch aggregator drains into a single `.repositoriesRemoved` terminal. `removingRepositoryIDs` is the source of truth for "this is a folder delete" so the intent survives a `git init` happening between confirmation and completion.
+23 -35
supacode/Domain/Repository.swift
··· 2 2 import IdentifiedCollections 3 3 import SupacodeSettingsShared 4 4 5 - private nonisolated let repositoryClassificationLogger = SupaLogger("RepositoryClassification") 6 - 7 5 struct Repository: Identifiable, Hashable, Sendable { 8 6 let id: String 9 7 let rootURL: URL ··· 34 32 } 35 33 36 34 /// Synchronous check for whether a root URL is a git repository. 37 - /// Covers the two layouts git actually produces: 38 - /// 1. `rootURL` IS the metadata dir — `.git/` for a normal repo, 39 - /// `.bare` for the naming convention Supacode's `name(for:)` 40 - /// helper already recognizes. Lastpathcomponent check. 41 - /// 2. Standard repo: `rootURL/.git` exists — a directory for 42 - /// primary repos, a worktree-pointer file for linked 43 - /// worktrees (also the git-wt bare wrapper, where `.git` is 44 - /// a pointer file to the sibling bare dir). 35 + /// Approximates git's own `is_git_directory()` heuristic so the 36 + /// result matches what `git` itself would accept as a repo root: 37 + /// 1. `.bare` / `.git` root names — cheap short-circuit covering 38 + /// Supacode's own `.bare` layout and the common `*.git` bare 39 + /// convention when the root is literally the metadata dir. 40 + /// 2. `rootURL/.git` exists (file or directory) — standard 41 + /// worktree root. Primary repo, linked worktree pointer, 42 + /// submodule, `--separate-git-dir` pointer, or the git-wt 43 + /// bare wrapper all surface through this one check. 44 + /// 3. `HEAD` + `objects` + `refs` all present at the root — any 45 + /// git dir (bare or otherwise) regardless of naming. Catches 46 + /// bare repos whose directory name does not end in `.git`. 47 + /// `HEAD` must be a regular file; git itself rejects a 48 + /// `HEAD` directory, so a directory with three child dirs 49 + /// named HEAD / objects / refs is not a repo. 45 50 /// Pure FileManager call — safe to invoke off the main actor from 46 51 /// the `GitClientDependency` closure. 47 52 nonisolated static func isGitRepository(at rootURL: URL) -> Bool { ··· 57 62 if fileManager.fileExists(atPath: dotGitPath) { 58 63 return true 59 64 } 60 - // Bare-clone convention: `<name>.git/` with HEAD + objects/ + refs/ 61 - // at the root (what `git clone --bare` produces). The name-only 62 - // `.bare` / `.git` shortcuts above cover Supacode's own layouts; 63 - // this catches user-managed bare clones imported via the Open panel. 64 - guard lastComponent.hasSuffix(".git") else { return false } 65 - let head = rootURL.appending(path: "HEAD", directoryHint: .notDirectory).path(percentEncoded: false) 66 - let objects = rootURL.appending(path: "objects", directoryHint: .isDirectory).path(percentEncoded: false) 67 - let refs = rootURL.appending(path: "refs", directoryHint: .isDirectory).path(percentEncoded: false) 68 - let hasHead = fileManager.fileExists(atPath: head) 69 - let hasObjects = fileManager.fileExists(atPath: objects) 70 - let hasRefs = fileManager.fileExists(atPath: refs) 71 - if hasHead && hasObjects && hasRefs { 72 - return true 73 - } 74 - // `.git`-suffixed directory missing one of the three structural 75 - // parts of a bare clone — log so the ambiguous "looks like a 76 - // damaged bare clone but classifies as a folder" case is 77 - // observable in telemetry without widening classification and 78 - // creating false positives for empty `.git` directories. 79 - repositoryClassificationLogger.warning( 80 - "Directory ending in .git missing bare-clone structure — " 81 - + "classified as folder. path=\(rootURL.path(percentEncoded: false)) " 82 - + "hasHead=\(hasHead) hasObjects=\(hasObjects) hasRefs=\(hasRefs)" 83 - ) 84 - return false 65 + let headPath = rootURL.appending(path: "HEAD", directoryHint: .notDirectory).path(percentEncoded: false) 66 + let objectsPath = rootURL.appending(path: "objects", directoryHint: .isDirectory).path(percentEncoded: false) 67 + let refsPath = rootURL.appending(path: "refs", directoryHint: .isDirectory).path(percentEncoded: false) 68 + var headIsDirectory: ObjCBool = false 69 + let headExists = fileManager.fileExists(atPath: headPath, isDirectory: &headIsDirectory) 70 + guard headExists, !headIsDirectory.boolValue else { return false } 71 + return fileManager.fileExists(atPath: objectsPath) 72 + && fileManager.fileExists(atPath: refsPath) 85 73 } 86 74 87 75 /// Prefix on folder-synthetic worktree ids. Single source of truth
+72
supacodeTests/RepositoriesFeatureTests.swift
··· 5094 5094 #expect(Repository.isGitRepository(at: fakeRoot) == false) 5095 5095 } 5096 5096 5097 + @Test func isGitRepositoryRecognizesBareRepositoryRegardlessOfName() throws { 5098 + // A bare repo does not have to be named `*.git` — classification 5099 + // should match git's own `is_git_directory()` heuristic (HEAD + 5100 + // objects + refs) regardless of the directory name. Covers bare 5101 + // clones the user renamed away from the `*.git` convention, 5102 + // which previously misclassified as folders. 5103 + let bareRoot = URL(fileURLWithPath: "/tmp/\(UUID().uuidString)-renamed-bare") 5104 + let fileManager = FileManager.default 5105 + try fileManager.createDirectory(at: bareRoot, withIntermediateDirectories: true) 5106 + try fileManager.createDirectory(at: bareRoot.appending(path: "objects"), withIntermediateDirectories: true) 5107 + try fileManager.createDirectory(at: bareRoot.appending(path: "refs"), withIntermediateDirectories: true) 5108 + try Data("ref: refs/heads/main\n".utf8).write(to: bareRoot.appending(path: "HEAD")) 5109 + defer { try? fileManager.removeItem(at: bareRoot) } 5110 + 5111 + #expect(Repository.isGitRepository(at: bareRoot)) 5112 + } 5113 + 5114 + @Test func isGitRepositoryRejectsDirectoryMissingGitStructure() throws { 5115 + // A directory with only some of the HEAD/objects/refs trio is 5116 + // not a git dir — git itself would reject it, and so must we. 5117 + // Prevents false positives from directories that coincidentally 5118 + // contain one or two of those names. 5119 + let partialRoot = URL(fileURLWithPath: "/tmp/\(UUID().uuidString)-partial") 5120 + let fileManager = FileManager.default 5121 + try fileManager.createDirectory(at: partialRoot, withIntermediateDirectories: true) 5122 + try fileManager.createDirectory( 5123 + at: partialRoot.appending(path: "objects"), 5124 + withIntermediateDirectories: true 5125 + ) 5126 + try Data("ref: refs/heads/main\n".utf8).write(to: partialRoot.appending(path: "HEAD")) 5127 + defer { try? fileManager.removeItem(at: partialRoot) } 5128 + 5129 + #expect(Repository.isGitRepository(at: partialRoot) == false) 5130 + } 5131 + 5132 + @Test func isGitRepositoryRejectsHeadDirectoryLookalike() throws { 5133 + // In a real git dir `HEAD` is a regular file holding a symbolic 5134 + // ref. A directory that happens to contain `HEAD/`, `objects/`, 5135 + // and `refs/` as directories is not a git dir — git itself 5136 + // rejects it. Guards against false positives on unrelated 5137 + // directories that coincidentally share those three names. 5138 + let lookalikeRoot = URL(fileURLWithPath: "/tmp/\(UUID().uuidString)-head-dir") 5139 + let fileManager = FileManager.default 5140 + try fileManager.createDirectory(at: lookalikeRoot, withIntermediateDirectories: true) 5141 + try fileManager.createDirectory( 5142 + at: lookalikeRoot.appending(path: "HEAD"), 5143 + withIntermediateDirectories: true 5144 + ) 5145 + try fileManager.createDirectory( 5146 + at: lookalikeRoot.appending(path: "objects"), 5147 + withIntermediateDirectories: true 5148 + ) 5149 + try fileManager.createDirectory( 5150 + at: lookalikeRoot.appending(path: "refs"), 5151 + withIntermediateDirectories: true 5152 + ) 5153 + defer { try? fileManager.removeItem(at: lookalikeRoot) } 5154 + 5155 + #expect(Repository.isGitRepository(at: lookalikeRoot) == false) 5156 + } 5157 + 5158 + @Test func isGitRepositoryReturnsFalseForNonexistentPath() { 5159 + // The caller (`applyRepositories` in `RepositoriesFeature`) 5160 + // gates on `rootDirectoryExists` before classifying, but the 5161 + // classifier itself is a pure helper and must still return a 5162 + // clean `false` for a missing path — no crash, no fallback 5163 + // to `true` — in case the existence gate is bypassed or a 5164 + // race deletes the directory between the two calls. 5165 + let missing = URL(fileURLWithPath: "/tmp/\(UUID().uuidString)-never-existed") 5166 + #expect(Repository.isGitRepository(at: missing) == false) 5167 + } 5168 + 5097 5169 @Test func loadPersistedRepositoriesClassifiesNonGitPathAsFolder() async { 5098 5170 let repoRoot = "/tmp/\(UUID().uuidString)-folder" 5099 5171 let rootURL = URL(fileURLWithPath: repoRoot)