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
24 changes: 23 additions & 1 deletion Sources/SubtreeLib/Commands/AddCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ public struct AddCommand: AsyncParsableCommand {
@Flag(name: .long, help: "Disable squash mode (preserves full upstream history)")
var noSquash: Bool = false

@Flag(name: .long, help: "Strip upstream submodule gitlinks after merge (persists to subtree.yaml)")
var stripGitlinks: Bool = false

public init() {}

public func run() async throws {
Expand Down Expand Up @@ -147,6 +150,24 @@ public struct AddCommand: AsyncParsableCommand {
Foundation.exit(1)
}

// Detect upstream submodule gitlinks brought in by the merge. If the user opted
// in via --strip-gitlinks, remove them and fold the removal into the atomic
// commit. Otherwise emit a non-destructive warning (Policy C) so the user can
// make an informed choice.
do {
let detected = try await GitOperations.findGitlinks(prefix: finalPrefix)
if !detected.isEmpty {
if stripGitlinks {
let removed = try await GitOperations.stripGitlinks(prefix: finalPrefix)
print("ℹ️ Stripped \(removed.count) upstream submodule gitlink(s) from \(finalPrefix)")
} else {
GitOperations.emitGitlinkWarning(prefix: finalPrefix, gitlinks: detected, command: "add")
}
}
} catch {
print("⚠️ Failed to inspect submodule gitlinks: \(error)")
}

// T040: Capture upstream commit hash from subtree trailer
// The split trailer contains the upstream commit hash, which is what we need
// for accurate up-to-date checks and stale trailer detection
Expand All @@ -173,7 +194,8 @@ public struct AddCommand: AsyncParsableCommand {
commit: commitHash,
tag: refType == "tag" ? finalRef : nil,
branch: refType == "branch" ? finalRef : nil,
squash: useSquash
squash: useSquash,
stripGitlinks: stripGitlinks ? true : nil
)

// Create new config with appended entry
Expand Down
45 changes: 43 additions & 2 deletions Sources/SubtreeLib/Commands/UpdateCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ public struct UpdateCommand: AsyncParsableCommand {
@Option(name: .long, help: "Specific ref (tag, branch, or commit) to update to")
var ref: String?

@Flag(name: .long, help: "Strip upstream submodule gitlinks after merge (persists to subtree.yaml)")
var stripGitlinks: Bool = false

public init() {}

// T039: Mutual exclusion validation
Expand Down Expand Up @@ -304,6 +307,24 @@ public struct UpdateCommand: AsyncParsableCommand {
let headAfterCommit = headAfter.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
let subtreePullCreatedCommit = headBeforeCommit != headAfterCommit

// Detect upstream submodule gitlinks. Strip only if the user opted in via
// the CLI flag or `stripGitlinks: true` in subtree.yaml. Otherwise emit a
// non-destructive warning (Policy C).
let effectiveStrip = self.stripGitlinks || (entry.stripGitlinks ?? false)
do {
let detected = try await GitOperations.findGitlinks(prefix: entry.prefix)
if !detected.isEmpty {
if effectiveStrip {
let removed = try await GitOperations.stripGitlinks(prefix: entry.prefix)
print("ℹ️ Stripped \(removed.count) upstream submodule gitlink(s) from \(entry.prefix)")
} else {
GitOperations.emitGitlinkWarning(prefix: entry.prefix, gitlinks: detected, command: "update")
}
}
} catch {
print("⚠️ Failed to inspect submodule gitlinks: \(error)")
}

// T021: Config update (new commit hash AND new tag if changed) after successful update
let updatedSubtrees = config.subtrees.map { subtree in
if subtree.name == entry.name {
Expand All @@ -316,7 +337,8 @@ public struct UpdateCommand: AsyncParsableCommand {
branch: newBranch,
squash: subtree.squash,
extracts: subtree.extracts,
extractions: subtree.extractions
extractions: subtree.extractions,
stripGitlinks: self.stripGitlinks ? true : subtree.stripGitlinks
)
}
return subtree
Expand Down Expand Up @@ -494,6 +516,24 @@ public struct UpdateCommand: AsyncParsableCommand {
let headAfterCommit = headAfter.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
let subtreePullCreatedCommit = headBeforeCommit != headAfterCommit

// Detect upstream submodule gitlinks. Strip only if the user opted in via
// the CLI flag or `stripGitlinks: true` in subtree.yaml. Otherwise emit a
// non-destructive warning (Policy C).
let effectiveStripAll = self.stripGitlinks || (entry.stripGitlinks ?? false)
do {
let detected = try await GitOperations.findGitlinks(prefix: entry.prefix)
if !detected.isEmpty {
if effectiveStripAll {
let removed = try await GitOperations.stripGitlinks(prefix: entry.prefix)
print("ℹ️ Stripped \(removed.count) upstream submodule gitlink(s) from \(entry.prefix)")
} else {
GitOperations.emitGitlinkWarning(prefix: entry.prefix, gitlinks: detected, command: "update")
}
}
} catch {
print("⚠️ Failed to inspect submodule gitlinks: \(error)")
}

// Update config (commit AND tag if changed)
let updatedSubtrees = config.subtrees.map { subtree in
if subtree.name == entry.name {
Expand All @@ -506,7 +546,8 @@ public struct UpdateCommand: AsyncParsableCommand {
branch: newBranch,
squash: subtree.squash,
extracts: subtree.extracts,
extractions: subtree.extractions
extractions: subtree.extractions,
stripGitlinks: self.stripGitlinks ? true : subtree.stripGitlinks
)
}
return subtree
Expand Down
17 changes: 16 additions & 1 deletion Sources/SubtreeLib/Configuration/Models/SubtreeEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@ public struct SubtreeEntry: Codable, Sendable {
/// Defines saved file extraction configurations from subtree to project
public let extractions: [ExtractionMapping]?

/// Optional flag to strip upstream submodule gitlinks (mode 160000) on add/update.
///
/// Some upstream repositories include git submodule gitlinks (test/fuzz/interop
/// tooling, etc). When `git subtree add/pull` merges that tree, the gitlinks come
/// along verbatim. Without matching `.gitmodules` entries, downstream tooling
/// (e.g. Swift Package Index's `git submodule update --init`) fails at clone time.
///
/// When set to `true`, the `add` and `update` commands automatically remove these
/// gitlinks from the index after each merge, folding the removal into the same
/// atomic commit. Default behavior (nil/false) is non-destructive: the CLI only
/// detects and warns when unmapped gitlinks are present.
public let stripGitlinks: Bool?

/// Initialize a subtree entry with required and optional fields
public init(
name: String,
Expand All @@ -50,7 +63,8 @@ public struct SubtreeEntry: Codable, Sendable {
branch: String? = nil,
squash: Bool? = nil,
extracts: [ExtractPattern]? = nil,
extractions: [ExtractionMapping]? = nil
extractions: [ExtractionMapping]? = nil,
stripGitlinks: Bool? = nil
) {
self.name = name
self.remote = remote
Expand All @@ -61,5 +75,6 @@ public struct SubtreeEntry: Codable, Sendable {
self.squash = squash
self.extracts = extracts
self.extractions = extractions
self.stripGitlinks = stripGitlinks
}
}
3 changes: 2 additions & 1 deletion Sources/SubtreeLib/Utilities/ConfigFileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,8 @@ public enum ConfigFileManager {
branch: subtree.branch,
squash: subtree.squash,
extracts: subtree.extracts,
extractions: extractions
extractions: extractions,
stripGitlinks: subtree.stripGitlinks
)

// Create new subtrees array with the updated entry
Expand Down
75 changes: 75 additions & 0 deletions Sources/SubtreeLib/Utilities/GitOperations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,81 @@ public enum GitOperations {
}
}

/// Find submodule gitlink (mode 160000) entries in the index under a prefix.
///
/// Some upstream repositories include git submodule gitlinks (test/fuzz/interop
/// dependencies, etc). When `git subtree add/pull` merges that tree, the gitlinks
/// come along verbatim. Without matching `.gitmodules` entries, downstream tooling
/// (e.g. Swift Package Index's `git submodule update --init`) fails at clone time:
///
/// fatal: No url found for submodule path '<prefix>/<sub>' in .gitmodules
///
/// This helper performs **detection only** — it does not mutate the index. Use
/// `stripGitlinks(prefix:)` to remove detected entries.
///
/// - Parameter prefix: Repository-relative prefix to scan (e.g. "Vendor/openssl")
/// - Returns: List of gitlink paths under the prefix (empty if none)
/// - Throws: GitError.commandFailed on git invocation failure
public static func findGitlinks(prefix: String) async throws -> [String] {
let lsResult = try await run(arguments: ["ls-files", "-s", "--", prefix])
guard lsResult.exitCode == 0 else {
throw GitError.commandFailed("git ls-files failed: \(lsResult.stderr)")
}

// ls-files -s output: "<mode> <hash> <stage>\t<path>"
var gitlinkPaths: [String] = []
for line in lsResult.stdout.split(separator: "\n", omittingEmptySubsequences: true) {
guard line.hasPrefix("160000 ") else { continue }
guard let tabIndex = line.firstIndex(of: "\t") else { continue }
gitlinkPaths.append(String(line[line.index(after: tabIndex)...]))
}

return gitlinkPaths
}

/// Remove submodule gitlink (mode 160000) entries from the index under a prefix.
///
/// Composes `findGitlinks(prefix:)` with `git rm --cached`. The removals are staged
/// so they can be folded into the same atomic commit that wraps the subtree add/update.
///
/// - Parameter prefix: Repository-relative prefix to scan (e.g. "Vendor/openssl")
/// - Returns: List of removed gitlink paths (empty if none found)
/// - Throws: GitError.commandFailed on git invocation failure
@discardableResult
public static func stripGitlinks(prefix: String) async throws -> [String] {
let gitlinkPaths = try await findGitlinks(prefix: prefix)
guard !gitlinkPaths.isEmpty else { return [] }

let rmResult = try await run(arguments: ["rm", "--cached", "-q", "--"] + gitlinkPaths)
guard rmResult.exitCode == 0 else {
throw GitError.commandFailed("git rm --cached failed: \(rmResult.stderr)")
}

return gitlinkPaths
}

/// Emit a Policy-C warning when unmapped gitlinks are present but the user has
/// not opted into stripping. Centralized so AddCommand and UpdateCommand stay
/// consistent.
///
/// - Parameters:
/// - prefix: Repository-relative prefix
/// - gitlinks: Detected gitlink paths (must be non-empty)
/// - command: The CLI subcommand to suggest re-running ("add" or "update")
public static func emitGitlinkWarning(prefix: String, gitlinks: [String], command: String) {
precondition(!gitlinks.isEmpty)
let count = gitlinks.count
let preview = gitlinks.prefix(3).joined(separator: ", ")
let suffix = count > 3 ? ", … (+\(count - 3) more)" : ""
print("""
⚠️ Found \(count) upstream submodule gitlink(s) under \(prefix): \(preview)\(suffix)
Without a matching .gitmodules entry, consumers running `git submodule update`
(e.g. Swift Package Index) will fail to clone this repository.
• If unwanted: re-run `subtree \(command)` with `--strip-gitlinks` (persists to subtree.yaml).
• If wanted: commit a .gitmodules file mapping each path to an upstream URL.
""")
}

// T006: Git subtree pull wrapper for update operations
/// Execute git subtree pull to update a subtree
///
Expand Down
Loading
Loading