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
16 changes: 8 additions & 8 deletions Sources/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 <name|path>"); 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)")
Expand All @@ -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) <name|path>"); 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
Expand Down
29 changes: 16 additions & 13 deletions Sources/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,44 +84,47 @@ 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
}
try? kept.joined(separator: "\n").write(toFile: path, atomically: true, encoding: .utf8)
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
let line = String(sub)
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
Expand Down
16 changes: 11 additions & 5 deletions Sources/RepoSpec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
43 changes: 43 additions & 0 deletions Tests/CLITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down