From 74a6ae4fb835cefe7836c70cde2f87b73b57b3b8 Mon Sep 17 00:00:00 2001 From: Will Howard Date: Tue, 28 Jul 2026 14:37:38 +0100 Subject: [PATCH] Persist the add target as an absolute path so 'gitwatchd .' works The CLI wrote the typed args verbatim to the config, so a relative target like '.' was later resolved against the daemon's working directory instead of the shell's. Only the target token is rewritten; flag values stay verbatim. Co-Authored-By: Claude Fable 5 --- Sources/CLI.swift | 16 +++++++++------- Sources/RepoSpec.swift | 29 ++++++++++++++++++----------- Tests/CLITests.swift | 30 ++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/Sources/CLI.swift b/Sources/CLI.swift index a7a4a81..da54e82 100644 --- a/Sources/CLI.swift +++ b/Sources/CLI.swift @@ -6,7 +6,8 @@ import AppKit enum CLI { static let version = "0.2.0" // keep in step with Resources/Info.plist - static func run(_ args: [String]) -> Int32 { + static func run(_ args: [String], + invokedFrom directory: String = FileManager.default.currentDirectoryPath) -> Int32 { guard let first = args.first else { printUsage(); return 0 } switch first { @@ -21,17 +22,17 @@ enum CLI { case "stop": return stopDaemon() case "config": return config(Array(args.dropFirst())) case "autostart": return autostart(Array(args.dropFirst())) - case "add": return add(Array(args.dropFirst())) + case "add": return add(Array(args.dropFirst()), invokedFrom: directory) default: // Bare form: `gitwatchd [flags] `: implicit add. - return add(args) + return add(args, invokedFrom: directory) } } // MARK: - add - private static func add(_ args: [String]) -> Int32 { - let (spec, err) = RepoSpecParser.parse(args) + private static func add(_ args: [String], invokedFrom directory: String) -> Int32 { + let (spec, err, resolved) = RepoSpecParser.parse(args, invokedFrom: directory) guard let spec else { warn(err ?? "could not parse arguments"); return 1 } guard FileManager.default.fileExists(atPath: spec.path) else { @@ -44,8 +45,9 @@ enum CLI { warn("already watching \(spec.name) (\(spec.path))"); return 1 } - // Persist exactly what the user typed (gitwatch-style line). - let line = args.map(Config.quoteIfNeeded).joined(separator: " ") + // Persist what the user typed (gitwatch-style line), with the target + // resolved: the daemon reads this file from its own directory. + let line = resolved.map(Config.quoteIfNeeded).joined(separator: " ") Config.append(line) ensureDaemonRunning() diff --git a/Sources/RepoSpec.swift b/Sources/RepoSpec.swift index 1d1d0c4..4bdede7 100644 --- a/Sources/RepoSpec.swift +++ b/Sources/RepoSpec.swift @@ -54,8 +54,14 @@ enum RepoSpecParser { /// Parse a gitwatch-style argument list into a RepoSpec. /// Returns nil + an error message if there's no valid target. static func parse(_ args: [String]) -> (spec: RepoSpec?, error: String?) { + let parsed = parse(args, invokedFrom: FileManager.default.currentDirectoryPath) + return (parsed.spec, parsed.error) + } + + static func parse(_ args: [String], invokedFrom directory: String) + -> (spec: RepoSpec?, error: String?, resolved: [String]) { var spec = RepoSpec(path: "") - var target: String? = nil + var target: (token: String, index: Int)? = nil var i = 0 func next() -> String? { i += 1; return i < args.count ? args[i] : nil } @@ -64,7 +70,7 @@ enum RepoSpecParser { switch a { case "-s": guard let v = next(), let d = Double(v), d >= 0 else { - return (nil, "-s needs a number of seconds, 0 or more") + return (nil, "-s needs a number of seconds, 0 or more", args) } spec.settle = d case "-d": if let v = next() { spec.dateFormat = v } @@ -76,13 +82,13 @@ enum RepoSpecParser { case "-C": spec.pipeChangedFiles = true // boolean: takes no argument case "-l", "-L": guard let v = next(), let n = Int(v), n >= 0 else { - return (nil, "\(a) needs a number of lines, 0 or more") + return (nil, "\(a) needs a number of lines, 0 or more", args) } spec.listChanges = n if a == "-L" { spec.listChangesColor = false } case "-x": guard let v = next(), (try? NSRegularExpression(pattern: v)) != nil else { - return (nil, "-x needs a valid regular expression") + return (nil, "-x needs a valid regular expression", args) } spec.exclude = v case "-M": spec.noMergeCommit = true @@ -93,22 +99,23 @@ enum RepoSpecParser { case "--paused": spec.paused = true // gitwatchd extension (see RepoSpec) default: if a.hasPrefix("-") { - return (nil, "unknown flag \(a)") + return (nil, "unknown flag \(a)", args) } else { - target = a // last bare arg wins as the target + target = (a, i) // last bare arg wins as the target } } i += 1 } - guard let t = target else { return (nil, "no target path given") } - spec.path = (t as NSString).expandingTildeInPath + 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 = (FileManager.default.currentDirectoryPath as NSString) - .appendingPathComponent(spec.path) + spec.path = (directory as NSString).appendingPathComponent(spec.path) } spec.path = (spec.path as NSString).standardizingPath - return (spec, nil) + var resolved = args + resolved[target.index] = spec.path + return (spec, nil, resolved) } /// The -d value goes to date(1) verbatim, as upstream: formats need a diff --git a/Tests/CLITests.swift b/Tests/CLITests.swift index 57c181c..ff43eab 100644 --- a/Tests/CLITests.swift +++ b/Tests/CLITests.swift @@ -48,6 +48,36 @@ struct CLIContract { } } + @Test("`gitwatchd .` persists the repo it was run in, not the dot") + func addResolvesRelativeTarget() { + withTemporaryConfig { + let repo = TestRepo() + #expect(CLI.run(["."], invokedFrom: repo.path) == 0) + #expect(Config.rawLines() == [repo.path]) + #expect(Config.specs()[0].path == repo.path, + "the daemon watches that repo, wherever it reads the config from") + } + } + + @Test("a tilde target expands and an absolute target survives unchanged") + func targetResolutionForms() { + let elsewhere = TestDirs.root + #expect(RepoSpecParser.parse(["~/code"], invokedFrom: elsewhere).resolved + == [(NSHomeDirectory() as NSString).appendingPathComponent("code")]) + #expect(RepoSpecParser.parse(["/tmp/x"], invokedFrom: elsewhere).resolved == ["/tmp/x"]) + } + + @Test("an invocation with no target is rejected as before, its args untouched") + func addWithoutTarget() { + withTemporaryConfig { + #expect(CLI.run(["add", "-s", "5"], invokedFrom: TestDirs.root) == 1) + #expect(Config.rawLines().isEmpty) + } + let parsed = RepoSpecParser.parse(["-s", "5"], invokedFrom: TestDirs.root) + #expect(parsed.error == "no target path given") + #expect(parsed.resolved == ["-s", "5"]) + } + @Test("adding the same repo twice fails and leaves one entry") func duplicateAdd() { withTemporaryConfig {