From f6aa1ae5f942d68bb801afe4dd8f0ffdf5b253f8 Mon Sep 17 00:00:00 2001 From: migsilva89 <85745515+migsilva89@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:13:59 +0100 Subject: [PATCH] Find the repository somebody points at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Choosing a project folder — one with a .git in it — answered "no repositories found in here". The chosen folder was only ever a place to look inside, and the .git that proves it is a repository is hidden, so the picker would not let it be chosen either: there was no way to say "this one". A chosen folder under git is now that project. A chosen folder with only a .claude still means look inside — that is ~/Projects with house instructions at the top, which once read as a single project and hid ninety — and becomes the project only when nothing turned up under it. The picker and Settings now say a repository itself is a valid answer. --- CHANGELOG.md | 12 +++++ Sources/LoadoutApp/ProjectsSettings.swift | 7 +-- Sources/LoadoutApp/SettingsView.swift | 7 +-- Sources/LoadoutApp/WelcomeSheet.swift | 5 ++- Sources/LoadoutCore/ProjectRoots.swift | 39 +++++++++++----- .../LoadoutCoreTests/ProjectRootsTests.swift | 44 +++++++++++++++++++ 6 files changed, 95 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7028e2b..d64c35d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ Notable changes, newest first. Dates are the day the work landed on `main`. The versions are what a release is tagged as; between tags, `main` is what is being used daily. +## Unreleased + +### Fixed + +- **Pointing Loadout at the repository you work in now finds it.** Choosing a project folder — one + with a `.git` and an `.opencode` in it — answered "no repositories found in here", because the + folder you chose was only ever a place to look *inside*, and the `.git` that proves it is a + repository is hidden and cannot be chosen in the picker either. A chosen folder under git is now + that project. A chosen folder with only a `.claude` still means look inside — that is + `~/Projects` with house instructions at the top — and becomes the project only when there is + nothing under it. Reported after install by somebody with one repository and no `~/Projects`. + ## 0.3.4 — 2026-08-28 ### Fixed diff --git a/Sources/LoadoutApp/ProjectsSettings.swift b/Sources/LoadoutApp/ProjectsSettings.swift index 035217f..66120b3 100644 --- a/Sources/LoadoutApp/ProjectsSettings.swift +++ b/Sources/LoadoutApp/ProjectsSettings.swift @@ -26,7 +26,8 @@ struct ProjectsSettings: View { // The depth is interpolated, not spelled out: the sentence said two while the search // went three deep, and the row below it printed the real number two lines away. note: "Loadout looks inside these for repositories — anything with a .git or a .claude " - + "inside, up to \(ProjectRoots.searchDepth) levels down.", + + "inside, up to \(ProjectRoots.searchDepth) levels down. A folder that is itself a " + + "repository counts as one, so you can point straight at the project you work in.", footnote: "A project's own skills and commands appear once you pick it from the scope " + "button above the list." ) { @@ -59,7 +60,7 @@ struct ProjectsSettings: View { .buttonStyle(V2ToolbarButtonStyle( prominent: model.projectRoots.folders.isEmpty, enabled: true )) - .help("Choose a folder Loadout should look in for repositories") + .help("Choose a folder that holds your repositories, or a repository itself") .pointingHand() } } @@ -117,7 +118,7 @@ struct ProjectsSettings: View { panel.canChooseFiles = false panel.allowsMultipleSelection = true panel.prompt = "Use folder" - panel.message = "Choose a folder Loadout should look in for repositories." + panel.message = "Choose a folder that holds your repositories — or a repository itself." guard panel.runModal() == .OK else { return } // Appended rather than replacing, and a folder chosen twice is not added twice. var folders = model.projectRoots.folders diff --git a/Sources/LoadoutApp/SettingsView.swift b/Sources/LoadoutApp/SettingsView.swift index 5adeb28..aac35b4 100644 --- a/Sources/LoadoutApp/SettingsView.swift +++ b/Sources/LoadoutApp/SettingsView.swift @@ -213,7 +213,7 @@ struct ProjectsTab: View { HStack { Button("Add folder…") { add() } - .help("Choose a folder Loadout should look in for repositories") + .help("Choose a folder that holds your repositories, or a repository itself") .pointingHand() Spacer() Text(found) @@ -225,7 +225,8 @@ struct ProjectsTab: View { } footer: { Text( "Loadout looks inside these for repositories — a folder with a .git or a " - + ".claude in it — up to two levels down. Each project's own skills and " + + ".claude in it — up to \(ProjectRoots.searchDepth) levels down, and a folder " + + "that is one itself counts. Each project's own skills and " + "commands then show up when you pick it from the scope button at the top of " + "the list." ) @@ -252,7 +253,7 @@ struct ProjectsTab: View { panel.canChooseFiles = false panel.allowsMultipleSelection = true panel.prompt = "Use folder" - panel.message = "Choose a folder Loadout should look in for repositories." + panel.message = "Choose a folder that holds your repositories — or a repository itself." guard panel.runModal() == .OK else { return } // Appended rather than replacing, and a folder chosen twice is not added twice. var folders = model.projectRoots.folders diff --git a/Sources/LoadoutApp/WelcomeSheet.swift b/Sources/LoadoutApp/WelcomeSheet.swift index 8da854a..060b736 100644 --- a/Sources/LoadoutApp/WelcomeSheet.swift +++ b/Sources/LoadoutApp/WelcomeSheet.swift @@ -233,7 +233,8 @@ struct WelcomeSheet: View { } if offered.isEmpty { - Text("Nothing obvious found — point Loadout at a folder and it will look inside.") + Text("Nothing obvious found — point Loadout at a folder it should look inside, or at a " + + "repository itself.") .font(.system(size: 12)) .foregroundStyle(V2.textMid) } else { @@ -391,7 +392,7 @@ struct WelcomeSheet: View { panel.canChooseFiles = false panel.allowsMultipleSelection = true panel.prompt = "Use folder" - panel.message = "Choose a folder Loadout should look in for repositories." + panel.message = "Choose a folder that holds your repositories — or a repository itself." guard panel.runModal() == .OK else { return } // Compared standardized, the way Settings › Projects does it. Raw `URL` equality counts // `~/Projects` and `~/Projects/` as two different folders, and the panel hands back the diff --git a/Sources/LoadoutCore/ProjectRoots.swift b/Sources/LoadoutCore/ProjectRoots.swift index 9a96891..c80fc68 100644 --- a/Sources/LoadoutCore/ProjectRoots.swift +++ b/Sources/LoadoutCore/ProjectRoots.swift @@ -111,6 +111,9 @@ public struct ProjectRoots: Sendable { let base = folder.standardizedFileURL.path let path = url.standardizedFileURL.path if path.hasPrefix(base + "/") { return String(path.dropFirst(base.count + 1)) } + // The chosen folder is the project itself: it reads as its own name, not as the whole + // path to it, which is what the row already sits under. + if path == base { return url.lastPathComponent } return abbreviate(url, home: home) } @@ -121,31 +124,45 @@ public struct ProjectRoots: Sendable { /// Hidden folders are skipped, which keeps this out of `.git` internals, and `node_modules` is /// skipped by name because it is deep, enormous, and never a project of yours. /// - /// The folder somebody *chose* is never itself the answer, even when it looks like a - /// repository. A real `~/Projects` had a `.claude` of its own — house instructions for the - /// whole tree — and the search stopped dead on it and reported one project where there were - /// ninety. What you point at is a place to look inside; only something nested can be the thing - /// found. + /// What somebody chose is first of all a place to look *inside*. A real `~/Projects` had a + /// `.claude` of its own — house instructions for the whole tree — and an earlier search + /// stopped dead on it and reported one project where there were ninety. + /// + /// It can also be the project. Somebody who points the app straight at the repository they + /// work in was told "no repositories found in here" while standing in their own checkout — + /// and the `.git` that proves otherwise is hidden, so the picker would not let them choose it + /// instead. So a chosen folder under git is that project, full stop: what is nested inside a + /// checkout is its submodules and packages, not other projects of yours. + /// + /// A chosen folder with only a `.claude` stays a place to look inside, and is taken as the + /// project just when nothing turned up in there. That is the `~/Projects` shape: house + /// instructions for a whole tree read as a project once, and hid ninety. static func repositories(under folder: URL, depth: Int = searchDepth, isRoot: Bool = true) -> [URL] { guard depth >= 0, isDirectory(folder) else { return [] } if !isRoot, isRepository(folder) { return [folder] } - guard depth > 0 else { return [] } + if isRoot, hasGit(folder) { return [folder] } + guard depth > 0 else { return isRoot && isRepository(folder) ? [folder] : [] } let fm = FileManager.default guard let children = try? fm.contentsOfDirectory( at: folder, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles] - ) else { return [] } + ) else { return isRoot && isRepository(folder) ? [folder] : [] } - return children + let nested = children .filter { isDirectory($0) && $0.lastPathComponent != "node_modules" } .sorted { $0.lastPathComponent < $1.lastPathComponent } .flatMap { repositories(under: $0, depth: depth - 1, isRoot: false) } + if isRoot, nested.isEmpty, isRepository(folder) { return [folder] } + return nested } static func isRepository(_ folder: URL) -> Bool { - isDirectory(folder.appendingPathComponent(".git")) - || FileManager.default.fileExists(atPath: folder.appendingPathComponent(".git").path) - || isDirectory(folder.appendingPathComponent(".claude")) + hasGit(folder) || isDirectory(folder.appendingPathComponent(".claude")) + } + + /// A `.git` directory, or the file a worktree or submodule leaves in its place. + static func hasGit(_ folder: URL) -> Bool { + FileManager.default.fileExists(atPath: folder.appendingPathComponent(".git").path) } static func isDirectory(_ url: URL) -> Bool { diff --git a/Tests/LoadoutCoreTests/ProjectRootsTests.swift b/Tests/LoadoutCoreTests/ProjectRootsTests.swift index bca4187..272a7c9 100644 --- a/Tests/LoadoutCoreTests/ProjectRootsTests.swift +++ b/Tests/LoadoutCoreTests/ProjectRootsTests.swift @@ -112,6 +112,50 @@ final class ProjectRootsTests: XCTestCase { XCTAssertFalse(found.contains { $0.name == "Projects" }, "the folder you chose is not a project") } + /// Pointing straight at the repository you work in finds it, instead of an empty list. + /// + /// Reported by somebody who chose their own project folder — a checkout with a `.git` and an + /// `.opencode` in it — and was told there were no repositories in there. The `.git` that would + /// have proved otherwise is hidden, so the picker would not let them choose it either. + func testChoosingARepositoryItselfFindsThatRepository() { + repo("dev/opencode-thing") + try! fm.createDirectory( + at: home.appendingPathComponent("dev/opencode-thing/.opencode"), + withIntermediateDirectories: true + ) + + let found = roots(["dev/opencode-thing"]).discover(home: home) + + XCTAssertEqual(found.map(\.name), ["opencode-thing"]) + XCTAssertEqual(found.map(\.relativePath), ["opencode-thing"], "shown by its name, not its path") + } + + /// And a chosen repository that also has repositories inside it still reads as one project, + /// rather than becoming its own submodules. + func testAChosenRepositoryDoesNotBecomeItsSubmodules() { + repo("dev/monorepo") + repo("dev/monorepo/packages/web") + + XCTAssertEqual(roots(["dev/monorepo"]).discover(home: home).map(\.name), ["monorepo"]) + } + + /// House instructions for a whole tree — a `.claude` at the top of `~/Projects` — lose to the + /// repositories underneath, which is the shape that made this rule in the first place. + func testAChosenFolderWithHouseInstructionsLosesToWhatIsInsideIt() { + try! fm.createDirectory( + at: home.appendingPathComponent("Projects/.claude"), withIntermediateDirectories: true + ) + repo("Projects/brain-box") + + XCTAssertEqual(roots(["Projects"]).discover(home: home).map(\.name), ["brain-box"]) + } + + /// An empty folder that is not a repository is still empty — the fallback does not invent one. + func testAnOrdinaryEmptyFolderStillFindsNothing() { + try! fm.createDirectory(at: home.appendingPathComponent("empty"), withIntermediateDirectories: true) + XCTAssertTrue(roots(["empty"]).discover(home: home).isEmpty) + } + /// Three levels down, which two was not: `~/Projects/PERSONAL/APPS/loadout` is a real path on a /// real machine, and a shallower limit found none of them while reporting success. func testARepositoryThreeLevelsDownIsFound() {