Improve handling of local_repositories

- Create the tulsi-workspace symlink during project generation in order
  to allow Xcode to resolve local_repository files without needing to
  build.
- External and local_repositories should no longer show up under
  @repository_name in the source filter UI. Instead, they will show up
  under external/repository_name to match Bazel.

PiperOrigin-RevId: 189064998
diff --git a/src/TulsiGenerator/Base.lproj/Localizable.strings b/src/TulsiGenerator/Base.lproj/Localizable.strings
index b4a2b98..9e4113d 100644
--- a/src/TulsiGenerator/Base.lproj/Localizable.strings
+++ b/src/TulsiGenerator/Base.lproj/Localizable.strings
@@ -112,3 +112,6 @@
 
 /* Warning when updating the user's DBGShellCommands script in ~/Library/Application Support/Tulsi failed. */
 "UpdatingDBGShellCommandsFailed" = "Failed to update the DBGShellCommands script used to locate dSYM bundles: received error '%1$@'.";
+
+/* Warning shown when failing to update the tulsi-workspace symlink to the Bazel execution root. tulsi-workspace path is in %1$@, additional error context in %2$@. */
+"UpdatingTulsiWorkspaceSymlinkFailed" = "Failed to update the %1$@ symlink to the Bazel execution root. Additional context: %2$@";
diff --git a/src/TulsiGenerator/BuildLabel.swift b/src/TulsiGenerator/BuildLabel.swift
index df5b6de..1f8e74d 100644
--- a/src/TulsiGenerator/BuildLabel.swift
+++ b/src/TulsiGenerator/BuildLabel.swift
@@ -46,9 +46,14 @@
   }()
 
   public lazy var asFileName: String? = { [unowned self] in
-    guard let package = self.packageName, let target = self.targetName else {
+    guard var package = self.packageName, let target = self.targetName else {
       return nil
     }
+    // Fix for external and local_repository, which may be referenced by Bazel via
+    // @repository//subpath while we internally refer to them via external/repository/subpath.
+    if package.starts(with: "@") {
+      package = "external/" + package.suffix(from: package.index(package.startIndex, offsetBy: 1))
+    }
     return "\(package)/\(target)"
   }()
 
diff --git a/src/TulsiGenerator/XcodeProjectGenerator.swift b/src/TulsiGenerator/XcodeProjectGenerator.swift
index 48e67fe..eab1042 100644
--- a/src/TulsiGenerator/XcodeProjectGenerator.swift
+++ b/src/TulsiGenerator/XcodeProjectGenerator.swift
@@ -237,6 +237,7 @@
     installStubExtensionPlistFiles(projectURL,
                                    rules: projectInfo.buildRuleEntries.filter { $0.pbxTargetType?.isiOSAppExtension ?? false },
                                    plistPaths: plistPaths)
+    linkTulsiWorkspace()
     return projectURL
   }
 
@@ -600,6 +601,57 @@
     }
   }
 
+  // Links tulsi-workspace to the current Bazel execution root. This may be overwritten during
+  // builds, but is useful to include in project generation for users who have local_repository
+  // references.
+  private func linkTulsiWorkspace() {
+    // Don't create the tulsi-workspace symlink for tests.
+    guard !self.redactWorkspaceSymlink else { return }
+
+    let path = workspaceRootURL.appendingPathComponent(PBXTargetGenerator.TulsiWorkspacePath,
+                                                       isDirectory: false).path
+    let bazelExecRoot = self.workspaceInfoExtractor.bazelExecutionRoot;
+
+    // See if tulsi-includes is already present.
+    if let attributes = try? fileManager.attributesOfItem(atPath: path) {
+      // If tulsi-includes is already a symlink, we only need to change it if it points to the wrong
+      // Bazel exec root.
+      if attributes[FileAttributeKey.type] as? FileAttributeType == FileAttributeType.typeSymbolicLink {
+        do {
+          let oldBazelExecRoot = try self.fileManager.destinationOfSymbolicLink(atPath: path)
+          guard oldBazelExecRoot != bazelExecRoot else { return }
+        } catch {
+          self.localizedMessageLogger.warning("UpdatingTulsiWorkspaceSymlinkFailed",
+                                              comment: "Warning shown when failing to update the tulsi-workspace symlink in %1$@ to the Bazel execution root, additional context %2$@.",
+                                              context: config.projectName,
+                                              values: path, "Unable to read old symlink. Was it modified?")
+          return
+        }
+      }
+
+      // The symlink exists but points to the wrong path or is a different file type. Remove it.
+      do {
+        try fileManager.removeItem(atPath: path)
+      } catch {
+        self.localizedMessageLogger.warning("UpdatingTulsiWorkspaceSymlinkFailed",
+                                            comment: "Warning shown when failing to update the tulsi-workspace symlink in %1$@ to the Bazel execution root, additional context %2$@.",
+                                            context: config.projectName,
+                                            values: path, "Unable to remove the old tulsi-workspace symlink. Trying removing it and try again.")
+        return
+      }
+    }
+
+    // Symlink tulsi-workspace ->  Bazel exec root.
+    do {
+      try self.fileManager.createSymbolicLink(atPath: path, withDestinationPath: bazelExecRoot)
+    } catch {
+      self.localizedMessageLogger.warning("UpdatingTulsiWorkspaceSymlinkFailed",
+                                          comment: "Warning shown when failing to update the tulsi-workspace symlink in %1$@ to the Bazel execution root, additional context %2$@.",
+                                          context: config.projectName,
+                                          values: path, "Creating symlink failed. Is it already present?")
+    }
+  }
+
   // Writes Xcode schemes for non-indexer targets if they don't already exist.
   private func installXcodeSchemesForProjectInfo(_ info: GeneratedProjectInfo,
                                                  projectURL: URL,
diff --git a/src/TulsiGeneratorTests/XcodeProjectGeneratorTests.swift b/src/TulsiGeneratorTests/XcodeProjectGeneratorTests.swift
index 071ec75..20fbf3b 100644
--- a/src/TulsiGeneratorTests/XcodeProjectGeneratorTests.swift
+++ b/src/TulsiGeneratorTests/XcodeProjectGeneratorTests.swift
@@ -345,6 +345,7 @@
                                       tulsiVersion: testTulsiVersion,
                                       fileManager: mockFileManager,
                                       pbxTargetGeneratorType: MockPBXTargetGenerator.self)
+    generator.redactWorkspaceSymlink = true
     generator.writeDataHandler = { (url, _) in
       self.writtenFiles.insert(url.path)
     }