Skip to content

Commit 5cdfb2a

Browse files
authored
Merge pull request #683 from aciidb0mb3r/workspace-edit
[Workspace] Add edit(dependency:at:) method
2 parents 7fab421 + faa236b commit 5cdfb2a

File tree

3 files changed

+157
-29
lines changed

3 files changed

+157
-29
lines changed

Sources/Commands/Workspace.swift

Lines changed: 93 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ public enum WorkspaceOperationError: Swift.Error {
2222

2323
/// The repository has uncommited changes.
2424
case hasUncommitedChanges(repo: AbsolutePath)
25+
26+
/// The dependency is already in edit mode.
27+
case dependencyAlreadyInEditMode
2528
}
2629

2730
/// The delegate interface used by the workspace to report status information.
@@ -79,7 +82,7 @@ public class Workspace {
7982
///
8083
/// Each dependency will have a checkout containing the sources at a
8184
/// particular revision, and may have an associated version.
82-
public struct ManagedDependency {
85+
public class ManagedDependency {
8386
/// The specifier for the dependency.
8487
public let repository: RepositorySpecifier
8588

@@ -89,20 +92,45 @@ public class Workspace {
8992
/// The current version of the dependency, if known.
9093
public let currentVersion: Version?
9194

95+
/// The dependency is in editable state i.e. user is expected to modify the sources of the dependency.
96+
/// The version of the dependency will not be considered during dependency resolution.
97+
var isInEditableState: Bool {
98+
return basedOn != nil
99+
}
100+
101+
/// A dependency which in editable state is based on a dependency from which it edited from.
102+
/// This information is useful so it can be restored when users unedit a package.
103+
let basedOn: ManagedDependency?
104+
92105
/// The current revision of the dependency.
93106
///
94107
/// This should always be a revision corresponding to the version in the
95108
/// repository, but in certain circumstances it may not be the *current*
96109
/// one (e.g., if this data is accessed with a different version of the
97110
/// package manager, which would cause an alternate version to be
98111
/// resolved).
99-
public let currentRevision: Revision
112+
public let currentRevision: Revision?
100113

101114
fileprivate init(repository: RepositorySpecifier, subpath: RelativePath, currentVersion: Version?, currentRevision: Revision) {
102115
self.repository = repository
103116
self.subpath = subpath
104117
self.currentVersion = currentVersion
105118
self.currentRevision = currentRevision
119+
self.basedOn = nil
120+
}
121+
122+
private init(basedOn dependency: ManagedDependency, subpath: RelativePath) {
123+
assert(!dependency.isInEditableState)
124+
self.basedOn = dependency
125+
self.repository = dependency.repository
126+
self.subpath = subpath
127+
self.currentRevision = nil
128+
self.currentVersion = nil
129+
}
130+
131+
/// Create an editable managed dependency based on a dependency which was *not* in edit state.
132+
func makingEditable(subpath: RelativePath) -> ManagedDependency {
133+
return ManagedDependency(basedOn: self, subpath: subpath)
106134
}
107135

108136
// MARK: Persistence
@@ -113,41 +141,45 @@ public class Workspace {
113141
case let .string(repositoryURL)? = contents["repositoryURL"],
114142
case let .string(subpathString)? = contents["subpath"],
115143
let currentVersionData = contents["currentVersion"],
116-
case let .string(currentRevisionString)? = contents["currentRevision"] else {
117-
return nil
118-
}
119-
let currentVersion: Version?
120-
switch currentVersionData {
121-
case .null:
122-
currentVersion = nil
123-
case .string(let string):
124-
currentVersion = Version(string)
125-
if currentVersion == nil {
126-
return nil
127-
}
128-
default:
144+
let basedOnData = contents["basedOn"],
145+
let currentRevisionString = contents["currentRevision"] else {
129146
return nil
130147
}
131148
self.repository = RepositorySpecifier(url: repositoryURL)
132149
self.subpath = RelativePath(subpathString)
133-
self.currentVersion = currentVersion
134-
self.currentRevision = Revision(identifier: currentRevisionString)
150+
self.currentVersion = ManagedDependency.optionalStringTransformer(currentVersionData, transformer: Version.init)
151+
self.currentRevision = ManagedDependency.optionalStringTransformer(currentRevisionString, transformer: Revision.init(identifier:))
152+
self.basedOn = ManagedDependency(json: basedOnData) ?? nil
135153
}
136154

137155
fileprivate func toJSON() -> JSON {
138-
let currentVersionData: JSON
139-
if let currentVersion = self.currentVersion {
140-
currentVersionData = .string(String(describing: currentVersion))
141-
} else {
142-
currentVersionData = .null
143-
}
144156
return .dictionary([
145157
"repositoryURL": .string(repository.url),
146158
"subpath": .string(subpath.asString),
147-
"currentVersion": currentVersionData,
148-
"currentRevision": .string(currentRevision.identifier),
159+
"currentVersion": ManagedDependency.optionalJSONTransformer(currentVersion) { .string(String(describing: $0)) },
160+
"currentRevision": ManagedDependency.optionalJSONTransformer(currentRevision) { .string($0.identifier) },
161+
"basedOn": basedOn?.toJSON() ?? .null,
149162
])
150163
}
164+
165+
// FIXME: Move these to JSON.
166+
private static func optionalStringTransformer<T>(_ value: JSON, transformer: (String) -> T?) -> T? {
167+
switch value {
168+
case .null:
169+
return nil
170+
case .string(let string):
171+
return transformer(string)
172+
default:
173+
return nil
174+
}
175+
}
176+
177+
private static func optionalJSONTransformer<T>(_ value: T?, transformer: (T) -> JSON) -> JSON {
178+
guard let value = value else {
179+
return .null
180+
}
181+
return transformer(value)
182+
}
151183
}
152184

153185
/// A struct representing all the current manifests (root + external) in a package graph.
@@ -199,6 +231,9 @@ public class Workspace {
199231
/// The path for working repository clones (checkouts).
200232
let checkoutsPath: AbsolutePath
201233

234+
/// The path where packages which are put in edit mode are checked out.
235+
let editablesPath: AbsolutePath
236+
202237
/// The manifest loader to use.
203238
let manifestLoader: ManifestLoaderProtocol
204239

@@ -209,7 +244,7 @@ public class Workspace {
209244
private let containerProvider: RepositoryPackageContainerProvider
210245

211246
/// The current state of managed dependencies.
212-
private var dependencyMap: [RepositorySpecifier: ManagedDependency]
247+
private(set) var dependencyMap: [RepositorySpecifier: ManagedDependency]
213248

214249
/// The known set of dependencies.
215250
public var dependencies: AnySequence<ManagedDependency> {
@@ -225,17 +260,20 @@ public class Workspace {
225260
/// - Parameters:
226261
/// - path: The path of the root package.
227262
/// - dataPath: The path for the workspace data files, if explicitly provided.
263+
/// - editablesPath: The path where editable packages should be placed, if explicitly provided.
228264
/// - manifestLoader: The manifest loader.
229265
/// - Throws: If the state was present, but could not be loaded.
230266
public init(
231267
rootPackage path: AbsolutePath,
232268
dataPath: AbsolutePath? = nil,
269+
editablesPath: AbsolutePath? = nil,
233270
manifestLoader: ManifestLoaderProtocol,
234271
delegate: WorkspaceDelegate
235272
) throws {
236273
self.delegate = delegate
237274
self.rootPackagePath = path
238275
self.dataPath = dataPath ?? path.appending(component: ".build")
276+
self.editablesPath = editablesPath ?? path.appending(component: "Packages")
239277
self.manifestLoader = manifestLoader
240278

241279
let repositoriesPath = self.dataPath.appending(component: "repositories")
@@ -286,6 +324,30 @@ public class Workspace {
286324
try removeFileTree(dataPath)
287325
}
288326

327+
/// Puts a dependency in edit mode creating a checkout in editables directory.
328+
func edit(dependency: ManagedDependency, at revision: Revision, packageName: String) throws {
329+
// Ensure that the dependency is not already in edit mode.
330+
guard !dependency.isInEditableState else {
331+
throw WorkspaceOperationError.dependencyAlreadyInEditMode
332+
}
333+
334+
// Compute new path for the dependency.
335+
let path = editablesPath.appending(component: packageName)
336+
337+
let handle = repositoryManager.lookup(repository: dependency.repository)
338+
// We should already have the handle if we're editing a dependency.
339+
assert(handle.isAvailable)
340+
341+
try handle.cloneCheckout(to: path, editable: true)
342+
let workingRepo = try repositoryManager.provider.openCheckout(at: path)
343+
try workingRepo.checkout(revision: revision)
344+
345+
// Change its stated to edited.
346+
dependencyMap[dependency.repository] = dependency.makingEditable(subpath: path.relative(to: editablesPath))
347+
// Save the state.
348+
try saveState()
349+
}
350+
289351
// MARK: Low-level Operations
290352

291353
/// Fetch a given `repository` and create a local checkout for it.
@@ -537,8 +599,11 @@ public class Workspace {
537599
let specifier = RepositorySpecifier(url: externalManifest.url)
538600
let managedDependency = dependencyMap[specifier]!
539601

540-
// If we know the manifest is at a particular version, use that.
541-
if let version = managedDependency.currentVersion {
602+
if managedDependency.isInEditableState {
603+
// FIXME: We need a way to state that we don't want any constaints on this dependency.
604+
fatalError("FIXME: Unimplemented.")
605+
} else if let version = managedDependency.currentVersion {
606+
// If we know the manifest is at a particular version, use that.
542607
// FIXME: This is broken, successor isn't correct and should be eliminated.
543608
constraints.append(RepositoryPackageConstraint(container: specifier, versionRequirement: .range(version..<version.successor())))
544609
} else {

Sources/SourceControl/GitRepository.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ public class GitRepository: Repository, WorkingCheckout {
257257
/// Gets the current list of remotes of the repository.
258258
///
259259
/// - Returns: An array of tuple containing name and url of the remote.
260-
func remotes() throws -> [(name: String, url: String)] {
260+
public func remotes() throws -> [(name: String, url: String)] {
261261
return try queue.sync {
262262
// Get the remote names.
263263
let remoteNamesOutput = try Git.runPopen([Git.tool, "-C", path.asString, "remote"]).chomp()

Tests/CommandsTests/WorkspaceTests.swift

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,8 +372,71 @@ final class WorkspaceTests: XCTestCase {
372372
}
373373
}
374374

375+
func testEditDependency() throws {
376+
mktmpdir { path in
377+
let manifestGraph = try MockManifestGraph(at: path,
378+
rootDeps: [
379+
MockDependency("A", version: Version(1, 0, 0)..<Version(1, .max, .max)),
380+
],
381+
packages: [
382+
MockPackage("A", version: v1),
383+
MockPackage("A", version: nil), // To load the edited package manifest.
384+
]
385+
)
386+
// Create the workspace.
387+
let workspace = try Workspace(rootPackage: path, manifestLoader: manifestGraph.manifestLoader, delegate: TestWorkspaceDelegate())
388+
// Load the package graph.
389+
let graph = try workspace.loadPackageGraph()
390+
// Sanity checks.
391+
XCTAssertEqual(graph.packages.count, 2)
392+
XCTAssertEqual(graph.packages.map{ $0.name }.sorted(), ["A", "Root"])
393+
394+
let manifests = try workspace.loadDependencyManifests()
395+
guard let aManifest = manifests.lookup("A") else {
396+
return XCTFail("Expected manifest for package A not found")
397+
}
398+
399+
func getDependency(_ manifest: Manifest) -> Workspace.ManagedDependency {
400+
return workspace.dependencyMap[RepositorySpecifier(url: manifest.url)]!
401+
}
402+
403+
// Get the dependency for package A.
404+
let dependency = getDependency(aManifest)
405+
// It should not be in edit mode.
406+
XCTAssert(!dependency.isInEditableState)
407+
// Put the dependency in edit mode at its current revision.
408+
try workspace.edit(dependency: dependency, at: dependency.currentRevision!, packageName: aManifest.name)
409+
410+
let editedDependency = getDependency(aManifest)
411+
// It should be in edit mode.
412+
XCTAssert(editedDependency.isInEditableState)
413+
// Check the based on data.
414+
XCTAssertEqual(editedDependency.basedOn?.subpath, dependency.subpath)
415+
XCTAssertEqual(editedDependency.basedOn?.currentVersion, dependency.currentVersion)
416+
XCTAssertEqual(editedDependency.basedOn?.currentRevision, dependency.currentRevision)
417+
418+
// Get the repo from edits path.
419+
let editRepo = GitRepository(path: workspace.editablesPath.appending(editedDependency.subpath))
420+
// Ensure that the editable checkout's remote points to the original repo path.
421+
XCTAssertEqual(try editRepo.remotes()[0].url, manifestGraph.repo("A").url)
422+
423+
do {
424+
try workspace.edit(dependency: editedDependency, at: dependency.currentRevision!, packageName: aManifest.name)
425+
XCTFail("Unexpected success, \(editedDependency) is already in edit mode")
426+
} catch WorkspaceOperationError.dependencyAlreadyInEditMode {}
427+
428+
do {
429+
// Reopen workspace and check if we maintained the state.
430+
let workspace = try Workspace(rootPackage: path, manifestLoader: manifestGraph.manifestLoader, delegate: TestWorkspaceDelegate())
431+
let dependency = workspace.dependencyMap[RepositorySpecifier(url: aManifest.url)]!
432+
XCTAssert(dependency.isInEditableState)
433+
}
434+
}
435+
}
436+
375437
static var allTests = [
376438
("testBasics", testBasics),
439+
("testEditDependency", testEditDependency),
377440
("testDependencyManifestLoading", testDependencyManifestLoading),
378441
("testPackageGraphLoadingBasics", testPackageGraphLoadingBasics),
379442
("testPackageGraphLoadingWithCloning", testPackageGraphLoadingWithCloning),

0 commit comments

Comments
 (0)