diff --git a/CHANGELOG.md b/CHANGELOG.md index fabfb53..7a92e4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ The English README is the source of truth; localized READMEs may lag. ### Fixed - Pruned stale simulator bindings before simulator selection, so a clone deleted outside vch no longer causes `vch build`, `vch test`, or `vch sim` commands to report false multi-binding ambiguity (#111). +- Made `vch remove` distinguish adopted tasks from vch-created tasks: adopted tasks now report that they were unregistered while keeping the external worktree/branch, and unregistered canonical-looking adopted worktrees no longer reappear in `vch list` or get treated as removable vch-owned worktrees (#112). ## 0.8.1 - 2026-05-14 diff --git a/Sources/VibeChardCore/Logic/WorkspaceLocator.swift b/Sources/VibeChardCore/Logic/WorkspaceLocator.swift index 636dca6..9d10118 100644 --- a/Sources/VibeChardCore/Logic/WorkspaceLocator.swift +++ b/Sources/VibeChardCore/Logic/WorkspaceLocator.swift @@ -65,6 +65,7 @@ public enum WorkspaceLocator { let taskName = taskNameForCurrentWorktree( toplevel: toplevel, workspace: workspace, + entries: entries, fs: fs ) return WorkspaceLocation( @@ -114,6 +115,7 @@ public enum WorkspaceLocator { private static func taskNameForCurrentWorktree( toplevel: String, workspace: Workspace, + entries: [WorktreeEntry], fs: FileSystem ) -> TaskName? { let normalizedTop = Workspace(mainWorktreePath: toplevel).mainWorktreePath @@ -134,6 +136,10 @@ public enum WorkspaceLocator { // `BeanLedger-foo bar`). Treat that as "not a vch worktree". if let raw = workspace.taskNameRaw(forWorktreePath: normalizedTop), let task = try? TaskName(raw) { + let entry = entries.first { + Workspace(mainWorktreePath: $0.path).mainWorktreePath == normalizedTop + } + guard entry?.branch == task.branchName else { return nil } return task } return nil diff --git a/Sources/VibeChardCore/Services/TaskService.swift b/Sources/VibeChardCore/Services/TaskService.swift index 214648b..e23f788 100644 --- a/Sources/VibeChardCore/Services/TaskService.swift +++ b/Sources/VibeChardCore/Services/TaskService.swift @@ -440,7 +440,7 @@ public struct TaskService: Sendable { let raw: String if let state { raw = state.name - } else if let inferred = workspace.taskNameRaw(forWorktreePath: entry.path) { + } else if let inferred = inferredVchCreatedTaskName(for: entry) { raw = inferred } else { continue @@ -509,13 +509,27 @@ public struct TaskService: Sendable { // MARK: - path - /// Resolve the absolute path of a task's worktree. Throws - /// `taskNotFound` if the directory does not exist. + /// Resolve the absolute path of a managed task's worktree. Throws + /// `taskNotFound` if the directory does not exist, or if a + /// state-less canonical-looking worktree is not on vch's own + /// `agent/` branch. public func pathForTask(_ task: TaskName) throws -> String { let p = workspace.worktreePath(for: task) if !fs.directoryExists(at: p) { throw VibeChardError.taskNotFound(name: task.raw) } + if fs.fileExists(at: PathOps.join(p, Workspace.stateJsonRelativePath)) { + return p + } + let entries = try git.worktreeList(repoCwd: workspace.mainWorktreePath) + let normalizedPath = Workspace(mainWorktreePath: p).mainWorktreePath + let isStatelessVchCreated = entries.contains { entry in + Workspace(mainWorktreePath: entry.path).mainWorktreePath == normalizedPath + && inferredVchCreatedTaskName(for: entry) == task.raw + } + guard isStatelessVchCreated else { + throw VibeChardError.taskNotFound(name: task.raw) + } return p } @@ -526,11 +540,8 @@ public struct TaskService: Sendable { /// `stateFileMissing` / `stateFileCorrupt` if the file is gone or /// malformed. public func stateForTask(_ task: TaskName) throws -> TaskState { - let wtPath = workspace.worktreePath(for: task) - if !fs.directoryExists(at: wtPath) { - throw VibeChardError.taskNotFound(name: task.raw) - } - let statePath = workspace.statePath(for: task) + let wtPath = try pathForTask(task) + let statePath = PathOps.join(wtPath, Workspace.stateJsonRelativePath) if !fs.fileExists(at: statePath) { throw VibeChardError.stateFileMissing(path: statePath) } @@ -569,10 +580,7 @@ public struct TaskService: Sendable { /// Git worktree and branch remain owned by the tool/user that made /// them. public func removeTask(_ task: TaskName, options: RemoveOptions = .init()) throws { - let wtPath = workspace.worktreePath(for: task) - if !fs.directoryExists(at: wtPath) { - throw VibeChardError.taskNotFound(name: task.raw) - } + let wtPath = try pathForTask(task) let state = try? stateForTask(task) if state?.worktreeOwnership == .adopted { @@ -617,6 +625,15 @@ public struct TaskService: Sendable { } } + private func inferredVchCreatedTaskName(for entry: WorktreeEntry) -> String? { + guard let raw = workspace.taskNameRaw(forWorktreePath: entry.path), + let task = try? TaskName(raw), + entry.branch == task.branchName else { + return nil + } + return raw + } + // MARK: - repair public struct RepairReport: Equatable, Sendable { diff --git a/Sources/vch/Commands/TaskLifecycleCommands.swift b/Sources/vch/Commands/TaskLifecycleCommands.swift index c6ca565..fb080d6 100644 --- a/Sources/vch/Commands/TaskLifecycleCommands.swift +++ b/Sources/vch/Commands/TaskLifecycleCommands.swift @@ -709,7 +709,12 @@ struct RemoveCommand: ParsableCommand { } } - print("removed \(task.raw)") + if state?.worktreeOwnership == .adopted { + let branch = state?.branch ?? "" + print("unregistered \(task.raw); kept external worktree \(wtPath) and branch \(branch)") + } else { + print("removed \(task.raw)") + } } } diff --git a/Tests/VchCLISmokeTests/VchCLISmokeTests.swift b/Tests/VchCLISmokeTests/VchCLISmokeTests.swift index c6c6043..14ce1a4 100644 --- a/Tests/VchCLISmokeTests/VchCLISmokeTests.swift +++ b/Tests/VchCLISmokeTests/VchCLISmokeTests.swift @@ -493,8 +493,9 @@ final class VchCLISmokeTests: XCTestCase { /// 3. `vch state --field worktreeOwnership` reports `adopted`. /// 4. `vch list --json` reports the adopted path AND the user's /// branch (`feature/codex`, not the synthetic `agent/`). - /// 5. `vch rm` deletes only vch-owned scratch under the adopted - /// worktree; the worktree itself and the user's branch are + /// 5. `vch rm` unregisters the task with explicit output, + /// deletes only vch-owned scratch under the adopted worktree, + /// and leaves the worktree itself and the user's branch /// untouched. func testAdoptCurrentEndToEnd() throws { try XCTSkipIf( @@ -581,6 +582,29 @@ final class VchCLISmokeTests: XCTestCase { ["rm", "codex-session"], cwd: sidecarPath, env: gitEnv ) XCTAssertEqual(rmResult.exitCode, 0, "stderr: \(rmResult.stderr)") + XCTAssertTrue( + rmResult.stdout.contains("unregistered codex-session"), + "adopted remove should say it unregistered the task, got: \(rmResult.stdout)" + ) + XCTAssertTrue( + rmResult.stdout.contains("kept external worktree"), + "adopted remove should explain that the external worktree remains, got: \(rmResult.stdout)" + ) + XCTAssertTrue( + rmResult.stdout.contains("feature/codex"), + "adopted remove should mention the preserved branch, got: \(rmResult.stdout)" + ) + + let listAfterRemoveResult = try runVch( + ["list", "--json"], cwd: sidecarPath, env: gitEnv + ) + XCTAssertEqual(listAfterRemoveResult.exitCode, 0, "stderr: \(listAfterRemoveResult.stderr)") + let listAfterRemoveData = Data(listAfterRemoveResult.stdout.utf8) + let listAfterRemoveJSON = try JSONSerialization.jsonObject(with: listAfterRemoveData) as? [[String: Any]] + XCTAssertEqual( + listAfterRemoveJSON?.count, 0, + "unregistered adopted task should disappear from vch list. raw: \(listAfterRemoveResult.stdout)" + ) // ---------- 6. Adopted worktree + branch survive ---------- var isDir: ObjCBool = false diff --git a/Tests/VibeChardCoreTests/Services/TaskServiceTests.swift b/Tests/VibeChardCoreTests/Services/TaskServiceTests.swift index 784f8e8..e151680 100644 --- a/Tests/VibeChardCoreTests/Services/TaskServiceTests.swift +++ b/Tests/VibeChardCoreTests/Services/TaskServiceTests.swift @@ -481,7 +481,8 @@ final class TaskServiceTests: XCTestCase { func testListSurvivesMissingStateFile() throws { let (service, git, _, _) = makeService() - // Worktree dir-pattern matches but no state.json on disk. + // Legacy vch-created worktree: dir-pattern and branch both + // match, but state.json is missing on disk. git.entries.append(WorktreeEntry(path: "/Users/me/Repo-orphan", branch: "agent/orphan")) let summaries = try service.listTasks() XCTAssertEqual(summaries.count, 1) @@ -489,6 +490,15 @@ final class TaskServiceTests: XCTestCase { XCTAssertNil(summaries.first?.createdAt) } + func testListSkipsCanonicalWorktreeWithoutStateOnExternalBranch() throws { + let (service, git, _, _) = makeService() + git.entries.append(WorktreeEntry(path: "/Users/me/Repo-external", branch: "feature/external")) + + let summaries = try service.listTasks() + + XCTAssertTrue(summaries.isEmpty) + } + func testListIncludesAdoptedWorktreeWithArbitraryPath() throws { let (service, git, fs, _) = makeService() let adoptedPath = "/Users/me/codex-session" @@ -571,8 +581,9 @@ final class TaskServiceTests: XCTestCase { } func testPathReturnsAbsolutePathWhenExists() throws { - let (service, _, fs, _) = makeService() + let (service, git, fs, _) = makeService() fs.seedDirectory("/Users/me/Repo-foo") + git.entries.append(WorktreeEntry(path: "/Users/me/Repo-foo", branch: "agent/foo")) let path = try service.pathForTask(TaskName("foo")) XCTAssertEqual(path, "/Users/me/Repo-foo") } @@ -589,10 +600,11 @@ final class TaskServiceTests: XCTestCase { } func testStateThrowsStateFileMissingWhenWorktreeExistsButNoStateFile() throws { - let (service, _, fs, _) = makeService() + let (service, git, fs, _) = makeService() // Worktree dir exists but the user (or a half-finished checkout) // never wrote .vch/state.json. fs.seedDirectory("/Users/me/Repo-foo") + git.entries.append(WorktreeEntry(path: "/Users/me/Repo-foo", branch: "agent/foo")) XCTAssertThrowsError(try service.stateForTask(TaskName("foo"))) { error in guard case let VibeChardError.stateFileMissing(path) = error else { return XCTFail("expected stateFileMissing, got \(error)") @@ -646,6 +658,15 @@ final class TaskServiceTests: XCTestCase { .withWorktreePath(adoptedPath, for: task) let fs = InMemoryFileSystem() fs.seedDirectory(adoptedPath) + let state = TaskState( + name: "codex-task", + branch: "feature/codex", + createdAt: Date(timeIntervalSince1970: 1_700_000_000), + baseRef: "abc1234", + worktreeOwnership: .adopted + ) + try fs.writeFileAtomic(state.jsonData(), + to: "\(adoptedPath)/.vch/state.json") let service = TaskService(workspace: workspace, git: FakeGitClient(), fs: fs, clock: FixedClock(Date())) @@ -779,6 +800,46 @@ final class TaskServiceTests: XCTestCase { XCTAssertTrue(git.branches.contains("feature/foo")) } + func testRemoveAdoptedCanonicalPathUnregistersWithoutRediscovery() throws { + let task = try TaskName("foo") + let adoptedPath = "/Users/me/Repo-foo" + let workspace = Workspace(mainWorktreePath: "/Users/me/Repo") + .withWorktreePath(adoptedPath, for: task) + let git = FakeGitClient() + git.entries = [ + WorktreeEntry(path: "/Users/me/Repo", branch: "main"), + WorktreeEntry(path: adoptedPath, branch: "feature/foo"), + ] + git.branches.insert("feature/foo") + let fs = InMemoryFileSystem() + fs.seedDirectory("/Users/me/Repo") + fs.seedDirectory(adoptedPath) + fs.seedFile("\(adoptedPath)/.agent-build/DerivedData/marker", data: Data("x".utf8)) + let state = TaskState( + name: "foo", + branch: "feature/foo", + createdAt: Date(), + baseRef: "abc1234", + worktreeOwnership: .adopted + ) + fs.seedFile("\(adoptedPath)/.vch/state.json", data: try state.jsonData()) + let service = TaskService(workspace: workspace, git: git, fs: fs) + + try service.removeTask(task) + + XCTAssertTrue(fs.directoryExists(at: adoptedPath)) + XCTAssertFalse(fs.directoryExists(at: "\(adoptedPath)/.vch")) + XCTAssertTrue(git.removeCalls.isEmpty) + XCTAssertTrue(try service.listTasks().isEmpty) + XCTAssertThrowsError(try service.removeTask(task, options: .forceAll)) { error in + guard case VibeChardError.taskNotFound = error else { + return XCTFail("expected taskNotFound, got \(error)") + } + } + XCTAssertTrue(git.removeCalls.isEmpty) + XCTAssertTrue(git.branches.contains("feature/foo")) + } + // MARK: - repair func testRepairPrunesAndCollectsProblems() throws { @@ -920,7 +981,7 @@ final class TaskServiceTests: XCTestCase { } func testGitStatusReportsMergedIntoBaseWhenAheadIsZero() throws { - let (service, git, _, _) = makeService() + let (service, _, _, _) = makeService() let summary = TaskSummary( name: "shipped", branch: "agent/shipped", diff --git a/docs/commands.md b/docs/commands.md index 099b4f8..9cd1902 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -352,7 +352,11 @@ vch remove [--allow-dirty] [--force] [--allow-unmerged] [--keep-sim] For vch-created tasks, delete the worktree, branch, and (by default) simulator clone. For `--adopt-current` tasks, unregister vch by removing only `.vch/` and `.agent-build/`; the external Git worktree -and branch remain intact. +and branch remain intact. Adopted-task removal prints +`unregistered ` instead of `removed ` to make that +ownership boundary explicit; after unregistering, the task no longer +appears in `vch list`. Use `git worktree remove` and `git branch -d` +manually if you also want to delete the external worktree and branch. - `--allow-dirty` permits uncommitted changes. - `--force` overrides the held-open-files check (e.g. an editor still