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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions Sources/VibeChardCore/Logic/WorkspaceLocator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public enum WorkspaceLocator {
let taskName = taskNameForCurrentWorktree(
toplevel: toplevel,
workspace: workspace,
entries: entries,
fs: fs
)
return WorkspaceLocation(
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
41 changes: 29 additions & 12 deletions Sources/VibeChardCore/Services/TaskService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<name>` 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
}

Expand All @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion Sources/vch/Commands/TaskLifecycleCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,12 @@ struct RemoveCommand: ParsableCommand {
}
}

print("removed \(task.raw)")
if state?.worktreeOwnership == .adopted {
let branch = state?.branch ?? "<unknown>"
print("unregistered \(task.raw); kept external worktree \(wtPath) and branch \(branch)")
} else {
print("removed \(task.raw)")
}
}
}

Expand Down
28 changes: 26 additions & 2 deletions Tests/VchCLISmokeTests/VchCLISmokeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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/<task>`).
/// 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(
Expand Down Expand Up @@ -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
Expand Down
69 changes: 65 additions & 4 deletions Tests/VibeChardCoreTests/Services/TaskServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -481,14 +481,24 @@ 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)
XCTAssertEqual(summaries.first?.name, "orphan")
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"
Expand Down Expand Up @@ -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")
}
Expand All @@ -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)")
Expand Down Expand Up @@ -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()))

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,11 @@ vch remove <name> [--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 <name>` instead of `removed <name>` 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
Expand Down