From 09ded95efe8c78b82e68dc7cd690849be6b587b8 Mon Sep 17 00:00:00 2001 From: Will Howard Date: Wed, 29 Jul 2026 17:32:22 +0000 Subject: [PATCH] Resolve relative paths in rm, pause and resume like add does Config.matches only compared the needle as a tilde path, a folder name or the stored path, so `gitwatchd rm .` inside a watched repo matched nothing while `add .` had stored the expanded path. Lookup now resolves a relative needle against the shell's working directory, through the same helper add uses; folder-name matching is unchanged. Fixes #11 Co-Authored-By: Claude Fable 5 --- Sources/CLI.swift | 16 ++++++++-------- Sources/Config.swift | 29 +++++++++++++++------------- Sources/RepoSpec.swift | 16 +++++++++++----- Tests/CLITests.swift | 43 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 26 deletions(-) diff --git a/Sources/CLI.swift b/Sources/CLI.swift index da54e82..f369906 100644 --- a/Sources/CLI.swift +++ b/Sources/CLI.swift @@ -13,9 +13,9 @@ enum CLI { switch first { case "help", "-h", "--help": printUsage(); return 0 case "version", "--version": print("gitwatchd \(version)"); return 0 - case "rm", "remove": return remove(Array(args.dropFirst())) - case "pause": return setPaused(Array(args.dropFirst()), true) - case "resume": return setPaused(Array(args.dropFirst()), false) + case "rm", "remove": return remove(Array(args.dropFirst()), invokedFrom: directory) + case "pause": return setPaused(Array(args.dropFirst()), true, invokedFrom: directory) + case "resume": return setPaused(Array(args.dropFirst()), false, invokedFrom: directory) case "status": return status() case "doctor": return doctor() case "start": return startDaemon() @@ -59,9 +59,9 @@ enum CLI { // MARK: - rm - private static func remove(_ args: [String]) -> Int32 { + private static func remove(_ args: [String], invokedFrom directory: String) -> Int32 { guard let needle = args.first else { warn("usage: gitwatchd rm "); return 1 } - let n = Config.remove(matching: needle) + let n = Config.remove(matching: needle, invokedFrom: directory) if n == 0 { warn("no watched repo matches \(needle)"); return 1 } ensureDaemonRunning() print("✓ stopped watching \(needle) (\(n) entr\(n == 1 ? "y" : "ies") removed)") @@ -70,12 +70,12 @@ enum CLI { // MARK: - pause / resume - private static func setPaused(_ args: [String], _ paused: Bool) -> Int32 { + private static func setPaused(_ args: [String], _ paused: Bool, invokedFrom directory: String) -> Int32 { let verb = paused ? "pause" : "resume" guard let needle = args.first else { warn("usage: gitwatchd \(verb) "); return 1 } - let n = Config.setPaused(matching: needle, paused: paused) + let n = Config.setPaused(matching: needle, paused: paused, invokedFrom: directory) guard n > 0 else { - let known = Config.specs().contains { Config.matches($0, needle) } + let known = Config.specs().contains { Config.matches($0, needle, invokedFrom: directory) } warn(known ? "\(needle) is already \(paused ? "paused" : "watching")" : "no watched repo matches \(needle)") return 1 diff --git a/Sources/Config.swift b/Sources/Config.swift index d7be455..70d9679 100644 --- a/Sources/Config.swift +++ b/Sources/Config.swift @@ -84,23 +84,26 @@ enum Config { try? text.write(toFile: path, atomically: true, encoding: .utf8) } - /// True if `spec` is the repo the user means by `needle`: full path, - /// tilde path, or folder name. - static func matches(_ spec: RepoSpec, _ needle: String) -> Bool { - spec.path == (needle as NSString).expandingTildeInPath - || spec.name == needle || spec.path == needle + /// True if `spec` is the repo the user means by `needle`: its folder name, + /// or its path in any form `add` accepts (absolute, tilde, or relative to + /// `directory`). + static func matches(_ spec: RepoSpec, _ needle: String, + invokedFrom directory: String = FileManager.default.currentDirectoryPath) -> Bool { + spec.name == needle || spec.path == needle + || spec.path == RepoSpecParser.resolve(needle, invokedFrom: directory) } /// Remove matching lines; returns how many were removed. @discardableResult - static func remove(matching needle: String) -> Int { + static func remove(matching needle: String, + invokedFrom directory: String = FileManager.default.currentDirectoryPath) -> Int { guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return 0 } var removed = 0 let kept = text.split(separator: "\n", omittingEmptySubsequences: false).filter { rawSub in let line = String(rawSub).trimmingCharacters(in: .whitespaces) if line.isEmpty || line.hasPrefix("#") { return true } guard let spec = RepoSpecParser.parse(tokenize(line)).spec else { return true } - let match = matches(spec, needle) + let match = matches(spec, needle, invokedFrom: directory) if match { removed += 1 } return !match } @@ -108,12 +111,12 @@ enum Config { return removed } - /// Flip the --paused token on config lines matching `needle` (by full - /// path or repo name, like remove). Pause lives in the config, not app - /// state, so it survives daemon and computer restarts. Returns the number - /// of lines changed. + /// Flip the --paused token on config lines matching `needle` (as remove + /// matches). Pause lives in the config, not app state, so it survives + /// daemon and computer restarts. Returns the number of lines changed. @discardableResult - static func setPaused(matching needle: String, paused: Bool) -> Int { + static func setPaused(matching needle: String, paused: Bool, + invokedFrom directory: String = FileManager.default.currentDirectoryPath) -> Int { guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return 0 } var changed = 0 let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map { sub -> String in @@ -121,7 +124,7 @@ enum Config { let trimmed = line.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty, !trimmed.hasPrefix("#"), let spec = RepoSpecParser.parse(tokenize(trimmed)).spec, - matches(spec, needle), + matches(spec, needle, invokedFrom: directory), let rewritten = togglingPaused(line: trimmed, path: spec.path, paused: paused) else { return line } changed += 1 diff --git a/Sources/RepoSpec.swift b/Sources/RepoSpec.swift index 4bdede7..3f24a03 100644 --- a/Sources/RepoSpec.swift +++ b/Sources/RepoSpec.swift @@ -108,16 +108,22 @@ enum RepoSpecParser { } guard let target else { return (nil, "no target path given", args) } - spec.path = (target.token as NSString).expandingTildeInPath - if !(spec.path as NSString).isAbsolutePath { - spec.path = (directory as NSString).appendingPathComponent(spec.path) - } - spec.path = (spec.path as NSString).standardizingPath + spec.path = resolve(target.token, invokedFrom: directory) var resolved = args resolved[target.index] = spec.path return (spec, nil, resolved) } + /// The absolute path a target token names, with a relative one taken from + /// `directory`: the shell's working directory, never the daemon's. + static func resolve(_ target: String, invokedFrom directory: String) -> String { + var path = (target as NSString).expandingTildeInPath + if !(path as NSString).isAbsolutePath { + path = (directory as NSString).appendingPathComponent(path) + } + return (path as NSString).standardizingPath + } + /// The -d value goes to date(1) verbatim, as upstream: formats need a /// leading "+", and a bad format splices an empty string. static func formattedDate(_ fmt: String) -> String { diff --git a/Tests/CLITests.swift b/Tests/CLITests.swift index ff43eab..49ff2ad 100644 --- a/Tests/CLITests.swift +++ b/Tests/CLITests.swift @@ -119,6 +119,37 @@ struct CLIContract { } } + @Test("`gitwatchd rm .` removes the repo it was run in") + func rmResolvesRelativeTarget() { + withTemporaryConfig { + let repo = TestRepo() + _ = CLI.run([repo.path]) + #expect(CLI.run(["rm", "."], invokedFrom: repo.path) == 0) + #expect(Config.specs().isEmpty) + } + } + + @Test("rm matches a relative path, resolved from the shell's directory") + func rmByRelativePath() { + withTemporaryConfig { + let repo = TestRepo() + _ = CLI.run([repo.path]) + let parent = (repo.path as NSString).deletingLastPathComponent + #expect(CLI.run(["rm", "./" + repo.spec().name], invokedFrom: parent) == 0) + #expect(Config.specs().isEmpty) + } + } + + @Test("rm by name still works from a directory unrelated to the repo") + func rmByNameFromElsewhere() { + withTemporaryConfig { + let repo = TestRepo() + _ = CLI.run([repo.path]) + #expect(CLI.run(["rm", repo.spec().name], invokedFrom: TestDirs.fresh("elsewhere")) == 0) + #expect(Config.specs().isEmpty) + } + } + @Test("rm of an unknown repo fails and leaves the config alone") func rmUnknown() { withTemporaryConfig { @@ -154,6 +185,18 @@ struct CLIContract { } } + @Test("pause and resume take `.` like add does") + func pauseResumeRelativeTarget() { + withTemporaryConfig { + let repo = TestRepo() + _ = CLI.run([repo.path]) + #expect(CLI.run(["pause", "."], invokedFrom: repo.path) == 0) + #expect(Config.specs()[0].paused) + #expect(CLI.run(["resume", "."], invokedFrom: repo.path) == 0) + #expect(!Config.specs()[0].paused) + } + } + @Test("pausing twice fails politely and changes nothing") func pauseTwice() { withTemporaryConfig {